diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py index d74e2bb5..2d95079b 100644 --- a/backend/api/v1/requests_app/views.py +++ b/backend/api/v1/requests_app/views.py @@ -1,12 +1,16 @@ from __future__ import annotations +import calendar +from datetime import date 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 requests_app.enums import RequestStatus +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 from drf_spectacular.utils import OpenApiParameter, extend_schema @@ -27,7 +31,6 @@ from rest_framework.serializers import ( BaseSerializer, ) # <- для типизации get_serializer_class -from ..permissions import IsSelfOrStaff from .permissions import ( CanViewRequest, CommentsPermission, @@ -41,6 +44,185 @@ 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) + + +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 _parse_iso_date(raw_value: str, field_name: str) -> date: + """Парсит ISO-даты для custom period.""" + try: + return date.fromisoformat(raw_value) + except ValueError as exc: + raise ValidationError( + {field_name: ["Ожидается дата в формате YYYY-MM-DD."]} + ) from exc + + +def _resolve_stats_window_from_query( + period: str, request: DRFRequest +) -> tuple[date | None, date | None]: + """Возвращает окно статистики, включая custom range.""" + if period != "custom": + return _resolve_stats_window(period) + + date_from_raw = (request.query_params.get("date_from") or "").strip() + date_to_raw = (request.query_params.get("date_to") or "").strip() + if not date_from_raw or not date_to_raw: + raise ValidationError( + { + "date_from": ["Обязательное поле для custom периода."], + "date_to": ["Обязательное поле для custom периода."], + } + ) + + start_date = _parse_iso_date(date_from_raw, "date_from") + end_date = _parse_iso_date(date_to_raw, "date_to") + if start_date > end_date: + raise ValidationError( + {"date_to": ["Дата окончания не может быть раньше даты начала."]} + ) + return start_date, end_date + + +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: date | None = None, + end_date: date | None = None, +): + if period != "custom": + 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, + "date_from": start_date.isoformat() if start_date else None, + "date_to": end_date.isoformat() if end_date else None, + "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 для заявок сотрудников. @@ -80,7 +262,11 @@ class RequestViewSet(viewsets.ModelViewSet): return [IsRecipientOfRequest()] if self.action == "cancel": return [IsRequestAuthor()] - if self.action == "retrieve": + 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": return [IsRequestAuthor(), NotFinalOrStaff()] @@ -210,6 +396,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 +667,71 @@ 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 + + if not _can_view_request_statistics(user): + raise PermissionDenied( + "У вас нет доступа к статистике по заявлениям." + ) + + period = (request.query_params.get("period") or "all").strip().lower() + if period not in {"all", "year", "month", "custom"}: + raise ValidationError( + "Параметр period должен быть one of: all, year, month, custom." + ) + start_date, end_date = _resolve_stats_window_from_query(period, request) + + return Response( + _build_employee_statistics( + employee_id=obj.employee_id, + employee_name=str(obj.employee), + period=period, + start_date=start_date, + end_date=end_date, + ) + ) + + @action(detail=False, methods=["get"], url_path="statistics") + def statistics(self, request: DRFRequest) -> Response: + """Возвращает статистику по выбранному сотруднику для страницы.""" + if not _can_view_request_statistics(request.user): + raise PermissionDenied( + "У вас нет доступа к статистике по заявлениям." + ) + + employee_id_raw = (request.query_params.get("employee_id") or "").strip() + if not employee_id_raw.isdigit(): + raise ValidationError("Параметр employee_id обязателен.") + + period = (request.query_params.get("period") or "all").strip().lower() + if period not in {"all", "year", "month", "custom"}: + raise ValidationError( + "Параметр period должен быть one of: all, year, month, custom." + ) + start_date, end_date = _resolve_stats_window_from_query(period, request) + + 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, + start_date=start_date, + end_date=end_date, + ) + ) + def create(self, request, *args, **kwargs): """ Создание заявки: валидируем write-сериализатором, diff --git a/backend/tests/api/v1/requests_app/test_requests_api.py b/backend/tests/api/v1/requests_app/test_requests_api.py index 11d16e9e..7563bbe2 100644 --- a/backend/tests/api/v1/requests_app/test_requests_api.py +++ b/backend/tests/api/v1/requests_app/test_requests_api.py @@ -1,9 +1,11 @@ from __future__ import annotations +from datetime import datetime from typing import Any import pytest from django.db import models +from django.utils import timezone from notifications.models import Notification from rest_framework.test import APIClient @@ -31,6 +33,122 @@ def test_list_unauth_401(api_client: APIClient) -> None: assert resp.status_code == 401 +def test_statistics_requires_privileged_access( + auth_client, regular_user: models.Model, make_user +) -> None: + """Статистика недоступна обычному пользователю без спецправ.""" + employee = make_user(email="stats-target@example.com") + + resp = auth_client(regular_user).get( + f"{API_BASE}statistics/?employee_id={employee.id}&period=all" + ) + + assert resp.status_code == 403 + + +def test_statistics_counts_all_employee_requests_for_admin( + auth_client, + admin_user: models.Model, + make_user, + make_request, +) -> None: + """Админ получает полную статистику по сотруднику, а не только видимые заявки.""" + employee = make_user(email="stats-admin-target@example.com") + make_request( + employee=employee, + type_=RequestType.SICK_LEAVE, + status=RequestStatus.APPROVED, + ) + make_request( + employee=employee, + type_=RequestType.DAY_OFF, + status=RequestStatus.PENDING, + ) + + resp = auth_client(admin_user).get( + f"{API_BASE}statistics/?employee_id={employee.id}&period=all" + ) + + assert resp.status_code == 200 + payload = resp.json() + assert payload["employee_id"] == employee.id + assert payload["period"] == "all" + assert payload["date_from"] is None + assert payload["date_to"] is None + assert payload["total_submitted_requests"] == 2 + assert payload["sick_leave_requests_count"] == 1 + assert payload["day_off_requests_count"] == 1 + + +def test_statistics_custom_period_filters_request_counts_and_absence_days( + auth_client, + admin_user: models.Model, + make_user, + make_request, +) -> None: + """Custom период фильтрует заявки по дате отправки и дни по пересечению периода.""" + employee = make_user(email="stats-custom-target@example.com") + + matching = make_request( + employee=employee, + type_=RequestType.VACATION, + status=RequestStatus.APPROVED, + comment="отпуск за свой счет", + ) + matching.created_at = datetime( + 2026, 4, 10, 12, 0, tzinfo=timezone.get_current_timezone() + ) + matching.date_from = datetime(2026, 4, 5).date() + matching.date_to = datetime(2026, 4, 12).date() + matching.save( + update_fields=["created_at", "date_from", "date_to", "comment"] + ) + + outside_count = make_request( + employee=employee, + type_=RequestType.DAY_OFF, + status=RequestStatus.APPROVED, + ) + outside_count.created_at = datetime( + 2026, 3, 1, 12, 0, tzinfo=timezone.get_current_timezone() + ) + outside_count.date_from = datetime(2026, 4, 11).date() + outside_count.date_to = datetime(2026, 4, 11).date() + outside_count.save(update_fields=["created_at", "date_from", "date_to"]) + + resp = auth_client(admin_user).get( + f"{API_BASE}statistics/?employee_id={employee.id}&period=custom" + "&date_from=2026-04-10&date_to=2026-04-15" + ) + + assert resp.status_code == 200 + payload = resp.json() + assert payload["period"] == "custom" + assert payload["date_from"] == "2026-04-10" + assert payload["date_to"] == "2026-04-15" + assert payload["total_submitted_requests"] == 1 + assert payload["day_off_requests_count"] == 0 + assert payload["unpaid_vacation_days"] == 3 + + +def test_statistics_custom_period_requires_valid_dates( + auth_client, admin_user: models.Model, make_user +) -> None: + """Custom период требует обе даты и валидный диапазон.""" + employee = make_user(email="stats-custom-validation@example.com") + + missing_resp = auth_client(admin_user).get( + f"{API_BASE}statistics/?employee_id={employee.id}&period=custom" + ) + invalid_resp = auth_client(admin_user).get( + f"{API_BASE}statistics/?employee_id={employee.id}&period=custom" + "&date_from=2026-04-20&date_to=2026-04-10" + ) + + assert missing_resp.status_code == 400 + assert invalid_resp.status_code == 400 + + def test_list_only_participants_see_requests( auth_client, regular_user: models.Model, make_user, make_request ) -> None: diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/src/app/requests/page.tsx b/frontend/src/app/requests/page.tsx index 4a00943c..ade3d01e 100644 --- a/frontend/src/app/requests/page.tsx +++ b/frontend/src/app/requests/page.tsx @@ -6,11 +6,13 @@ 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"; import { useRequestsPage } from "@/hooks/useRequestsPage"; import { useRequestsPageScreen } from "@/hooks/useRequestsPageScreen"; +import { canViewRequestStatistics } from "@/lib/permissions"; export default function RequestsPage() { return ( @@ -33,6 +35,7 @@ function RequestsPageFallback() { function RequestsPageContent() { const { user } = useUser(); + const canViewStats = canViewRequestStatistics(user); const h = useRequestsPage(user?.id); const screen = useRequestsPageScreen({ detailsRequestId: h.detailsRequest?.id, @@ -87,72 +90,84 @@ function RequestsPageContent() { ) : h.error ? (

{h.error}

) : ( -
- {h.swipeMode && h.pendingDecisionRequests.length > 0 ? ( - h.setSwipeMode(false)} - onReject={h.handleReject} - requests={h.pendingDecisionRequests} + <> + +
+ {h.swipeMode && h.pendingDecisionRequests.length > 0 ? ( + h.setSwipeMode(false)} + onReject={h.handleReject} + requests={h.pendingDecisionRequests} + /> + ) : ( + <> + - ) : ( - <> - - - - )} -
+ + + )} +
+ )} { screen.closeDetailsRequest(); h.openEdit(request); }} onPreviewAttachment={h.setAttachmentPreview} onReject={h.handleReject} + onSetCommentDraft={h.setCommentDraft} request={h.detailsRequest} /> diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index f7602df8..a97900fb 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -177,12 +177,19 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa
-
+
{ + e.preventDefault(); + submitSearch(); + }} + > setSearchQuery(e.target.value)} + enterKeyHint="search" onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); @@ -192,7 +199,7 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa className="app-input w-full rounded-full py-2.5 pl-11 pr-4 text-sm" placeholder="Поиск" /> -
+
@@ -250,13 +257,21 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa isSearchOpen ? "max-h-20 pb-3" : "max-h-0" } ${isBottomMobileNav ? "absolute inset-x-0 bottom-full px-4 sm:px-8" : ""}`} > -
+
{ + e.preventDefault(); + submitSearch(); + setIsSearchOpen(false); + }} + > setSearchQuery(e.target.value)} + enterKeyHint="search" onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); @@ -267,10 +282,18 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa setIsSearchOpen(false); } }} - className="app-input w-full rounded-full py-2.5 pl-11 pr-4 text-sm" + className="app-input w-full rounded-full py-2.5 pl-11 pr-12 text-sm" placeholder="Поиск по сайту..." /> -
+ +
{/* Мобильная панель уведомлений */} diff --git a/frontend/src/components/requests/RequestDetailModal.tsx b/frontend/src/components/requests/RequestDetailModal.tsx index 445205b8..f8ef851c 100644 --- a/frontend/src/components/requests/RequestDetailModal.tsx +++ b/frontend/src/components/requests/RequestDetailModal.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Modal } from "@/components/ui"; import { defaultStatusMeta, @@ -5,8 +7,9 @@ import { statusMeta, type RequestAttachmentPreview, } from "@/hooks/useRequestsPage"; -import { formatDate, formatDateTime } from "@/lib/shared"; -import type { Request } from "@/types/api"; +import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls"; +import { displayUserName, formatDate, formatDateTime } from "@/lib/shared"; +import type { Request, RequestComment } from "@/types/api"; import { Ban, Paperclip, Pencil, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"; import { getRequestActionState } from "./requestActions"; import { RequestUserBadge } from "./RequestUserBadge"; @@ -14,32 +17,44 @@ import { RequestUserBadge } from "./RequestUserBadge"; type RequestDetailModalProps = { actionError?: string | null; busyKey: string | null; + commentDraft: string; + comments: RequestComment[]; + commentsLoading: boolean; currentUserId?: number | null; departmentNameMap: Map; isFinal: (status?: string) => boolean; + onAddComment: (requestId: number) => void | Promise; onApprove: (requestId: number) => void | Promise; onCancel: (requestId: number) => void | Promise; onClose: () => void; onDelete: (requestId: number) => void | Promise; + onDeleteComment: (requestId: number, commentId: number) => void | Promise; onEdit: (request: Request) => void; onPreviewAttachment: (preview: RequestAttachmentPreview) => void; onReject: (requestId: number) => void | Promise; + onSetCommentDraft: (requestId: number, value: string) => void; request: Request | null; }; export function RequestDetailModal({ actionError, busyKey, + commentDraft, + comments, + commentsLoading, currentUserId, departmentNameMap, isFinal, + onAddComment, onApprove, onCancel, onClose, onDelete, + onDeleteComment, onEdit, onPreviewAttachment, onReject, + onSetCommentDraft, request, }: RequestDetailModalProps) { if (!request) return null; @@ -47,7 +62,7 @@ export function RequestDetailModal({ const author = request.employee || request.created_by; const approver = request.approver || request.assigned_to; const actionState = getRequestActionState(request, currentUserId, isFinal); - const { canCancel, canDelete, canEdit, canProcess } = actionState; + const { canCancel, canComment, canDelete, canEdit, canProcess } = actionState; const status = statusMeta[String(request.status || "").toLowerCase()] ?? defaultStatusMeta; const typeLabel = requestTypeLabels[String(request.type || request.request_type || "").toLowerCase()] || String(request.type || request.request_type || "Другое"); @@ -55,6 +70,7 @@ export function RequestDetailModal({ const attachmentName = attachmentUrl ? decodeURIComponent(attachmentUrl.split("/").pop() || "Вложение") : ""; + const commentCount = request.comments_count ?? comments.length; return (
-
-
-
- - {typeLabel} - - - {status.label} - -
-

- {request.display_title || request.title || "Без заголовка"} -

- {(request.comment || request.description) ? ( -
- {request.comment || request.description} +
+
+
+
+ + {typeLabel} + + + {status.label} +
- ) : ( -

Описание отсутствует

- )} +

+ {request.display_title || request.title || "Без заголовка"} +

+ {(request.comment || request.description) ? ( +
+ {request.comment || request.description} +
+ ) : ( +

Описание отсутствует

+ )} +
+
+ +
+
+
+

Комментарии

+ + {commentCount} + +
+ + {!canComment ? ( +

+ Комментарии будут доступны после отправки заявления. +

+ ) : ( +
+
+ {commentsLoading ? ( +
+
+ Загрузка комментариев... +
+ ) : comments.length > 0 ? ( + comments.map((comment) => ( +
+
+
+

+ {displayUserName(comment.author)} +

+

+ {formatDateTime(comment.created_at) || formatDate(comment.created_at)} +

+
+ {Boolean(comment.author?.id && currentUserId === comment.author.id) ? ( + onDeleteComment(request.id, comment.id)} + /> + ) : null} +
+

+ {comment.text} +

+
+ )) + ) : ( +

Комментариев пока нет

+ )} +
+ + onSetCommentDraft(request.id, value)} + onSubmit={() => onAddComment(request.id)} + placeholder="Добавить комментарий" + /> +
+ )} +
-
+

Даты

@@ -207,81 +290,85 @@ export function RequestDetailModal({
-
-

Отделы

- {(request.departments || []).length > 0 ? ( -
- {(request.departments || []).map((id) => ( - - {departmentNameMap.get(Number(id)) || `Отдел #${id}`} - - ))} -
- ) : ( -

Отделы не указаны

- )} +
+
+

Отделы

+ {(request.departments || []).length > 0 ? ( +
+ {(request.departments || []).map((id) => ( + + {departmentNameMap.get(Number(id)) || `Отдел #${id}`} + + ))} +
+ ) : ( +

Отделы не указаны

+ )} +
+ +
+

Получатели

+ {(request.recipients || []).length > 0 ? ( +
+ {(request.recipients || []).map((recipient) => ( + + ))} +
+ ) : ( +

Получатели не указаны

+ )} +
-
-

Получатели

- {(request.recipients || []).length > 0 ? ( -
- {(request.recipients || []).map((recipient) => ( - - ))} -
- ) : ( -

Получатели не указаны

- )} -
+
+
+

В копии

+ {(request.cc_users || []).length > 0 ? ( +
+ {(request.cc_users || []).map((ccUser) => ( + + ))} +
+ ) : ( +

Копия не указана

+ )} +
-
-

В копии

- {(request.cc_users || []).length > 0 ? ( -
- {(request.cc_users || []).map((ccUser) => ( - - ))} -
- ) : ( -

Копия не указана

- )} -
- -
-

Вложение

- {attachmentUrl ? ( -
- - - Открыть в новой вкладке - -
- ) : ( -

Вложение отсутствует

- )} +
+

Вложение

+ {attachmentUrl ? ( +
+ + + Открыть в новой вкладке + +
+ ) : ( +

Вложение отсутствует

+ )} +
diff --git a/frontend/src/components/requests/RequestStatisticsPanel.tsx b/frontend/src/components/requests/RequestStatisticsPanel.tsx new file mode 100644 index 00000000..21ae4759 --- /dev/null +++ b/frontend/src/components/requests/RequestStatisticsPanel.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { apiClient } from "@/lib/api"; +import { displayUserName } from "@/lib/shared"; +import { resolveMediaUrl } from "@/lib/url"; +import type { RequestEmployeeStatistics, User } from "@/types/api"; +import { ChevronDown, Search } from "lucide-react"; +import { useEffect, useState } from "react"; + +const statsPeriods = [ + { value: "all", label: "За всё время" }, + { value: "year", label: "За год" }, + { value: "month", label: "За месяц" }, + { value: "custom", 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 [searchQuery, setSearchQuery] = useState(""); + const [employeeId, setEmployeeId] = useState(null); + const [period, setPeriod] = useState("all"); + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if ( + !canView + || !open + || !employeeId + || (period === "custom" && (!dateFrom || !dateTo)) + ) { + return; + } + + let cancelled = false; + setLoading(true); + setError(null); + + const query = new URLSearchParams({ + employee_id: String(employeeId), + period, + }); + if (period === "custom") { + query.set("date_from", dateFrom); + query.set("date_to", dateTo); + } + + apiClient.request( + `/api/v1/requests/statistics/?${query.toString()}` + ) + .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, dateFrom, dateTo, employeeId, open, period]); + + if (!canView) return null; + + const filteredEmployees = employees.filter((employee) => { + const normalizedQuery = searchQuery.trim().toLowerCase(); + if (!normalizedQuery) return true; + + const haystack = [ + displayUserName(employee), + employee.email || "", + ] + .join(" ") + .toLowerCase(); + + return haystack.includes(normalizedQuery); + }); + + 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 ? ( +
+
+
+ + setSearchQuery(e.target.value)} + placeholder="Выберите сотрудника для статистики" + className="app-input w-full rounded-xl py-2.5 pl-10 pr-3 text-sm" + /> +
+ +
+ Найдено: {filteredEmployees.length} + Выбрано: {employeeId ? 1 : 0} +
+ +
+ {filteredEmployees.length > 0 ? ( + filteredEmployees.map((employee) => { + const isSelected = employee.id === employeeId; + const employeeName = displayUserName(employee); + return ( + + ); + }) + ) : ( +

Сотрудники не найдены

+ )} +
+ +
+ {statsPeriods.map((periodOption) => { + const isActive = period === periodOption.value; + return ( + + ); + })} +
+ + {period === "custom" ? ( +
+ + +
+ ) : null} +
+ + {!employeeId ? ( +

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

+ ) : period === "custom" && (!dateFrom || !dateTo) ? ( +

+ Укажите даты начала и окончания для выбранного периода. +

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

+ {period === "all" + ? "Период: за всё время" + : period === "year" + ? "Период: текущий календарный год" + : period === "month" + ? "Период: текущий календарный месяц" + : `Период: ${stats?.date_from || dateFrom} — ${stats?.date_to || dateTo}`} +

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

{item.label}

+

{item.value}

+
+ ))} +
+
+ )} +
+ ) : null} +
+ ); +} diff --git a/frontend/src/hooks/useRequestsPage.ts b/frontend/src/hooks/useRequestsPage.ts index aa061ebf..601e79c6 100644 --- a/frontend/src/hooks/useRequestsPage.ts +++ b/frontend/src/hooks/useRequestsPage.ts @@ -109,6 +109,7 @@ export function useRequestsPage(_userId: number | null | undefined) { const [employees, setEmployees] = useState([]); const [departments, setDepartments] = useState([]); const [commentsMap, setCommentsMap] = useState>({}); + const [commentsLoadingMap, setCommentsLoadingMap] = useState>({}); const [expandedRows, setExpandedRows] = useState>({}); const [expandedComments, setExpandedComments] = useState>({}); const [commentDrafts, setCommentDrafts] = useState>({}); @@ -427,12 +428,25 @@ export function useRequestsPage(_userId: number | null | undefined) { }; /* ── comments ── */ + const ensureCommentsLoaded = useCallback(async (requestId: number) => { + if (commentsMap[requestId] || commentsLoadingMap[requestId]) return; + + setCommentsLoadingMap((prev) => ({ ...prev, [requestId]: true })); + try { + const comments = await apiClient.getRequestComments(requestId); + setCommentsMap((prev) => ({ ...prev, [requestId]: comments })); + } catch { + setCommentsMap((prev) => ({ ...prev, [requestId]: [] })); + } finally { + setCommentsLoadingMap((prev) => ({ ...prev, [requestId]: false })); + } + }, [commentsLoadingMap, commentsMap]); + const toggleComments = async (requestId: number) => { const isOpen = Boolean(expandedComments[requestId]); setExpandedComments((p) => ({ ...p, [requestId]: !isOpen })); if (!isOpen && !commentsMap[requestId]) { - try { const c = await apiClient.getRequestComments(requestId); setCommentsMap((p) => ({ ...p, [requestId]: c })); } - catch { setCommentsMap((p) => ({ ...p, [requestId]: [] })); } + await ensureCommentsLoaded(requestId); } }; @@ -477,6 +491,13 @@ export function useRequestsPage(_userId: number | null | undefined) { return () => { if (el) observer.unobserve(el); }; }, [nextPage, loadingMore, loading, handleLoadMore]); + useEffect(() => { + const requestId = detailsRequest?.id; + if (!requestId) return; + if (String(detailsRequest.status || "").toLowerCase() === "draft") return; + void ensureCommentsLoaded(requestId); + }, [detailsRequest, ensureCommentsLoaded]); + return { /* data */ requests: filteredRequests, @@ -485,6 +506,7 @@ export function useRequestsPage(_userId: number | null | undefined) { departments, departmentNameMap, commentsMap, + commentsLoadingMap, expandedRows, expandedComments, commentDrafts, diff --git a/frontend/src/lib/api/requests.ts b/frontend/src/lib/api/requests.ts index 13128acd..dae2f4f7 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,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, 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/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..21be7364 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -369,6 +369,21 @@ export interface RequestComment { created_at: string; } +export interface RequestEmployeeStatistics { + employee_id: number; + employee_name: string; + period: "all" | "year" | "month" | "custom"; + date_from: string | null; + date_to: string | null; + 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;