Add employee request statistics panel

This commit is contained in:
lizerk 2026-04-20 12:25:13 +03:00
parent f538b95cf9
commit 824728019b
6 changed files with 256 additions and 6 deletions

View File

@ -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-сериализатором,

View File

@ -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() {
<RequestDetailModal
actionError={h.actionError}
busyKey={h.busyKey}
canViewStats={canViewStats}
commentDraft={h.detailsRequest ? (h.commentDrafts[h.detailsRequest.id] || "") : ""}
comments={h.detailsRequest ? (h.commentsMap[h.detailsRequest.id] || []) : []}
commentsLoading={h.detailsRequest ? Boolean(h.commentsLoadingMap[h.detailsRequest.id]) : false}

View File

@ -1,3 +1,5 @@
"use client";
import { Modal } from "@/components/ui";
import {
defaultStatusMeta,
@ -5,16 +7,19 @@ import {
statusMeta,
type RequestAttachmentPreview,
} from "@/hooks/useRequestsPage";
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 { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { apiClient } from "@/lib/api";
import { displayUserName, formatDate, formatDateTime } from "@/lib/shared";
import type { Request, RequestComment, RequestEmployeeStatistics } from "@/types/api";
import { Ban, ChevronDown, Paperclip, Pencil, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { getRequestActionState } from "./requestActions";
import { RequestUserBadge } from "./RequestUserBadge";
type RequestDetailModalProps = {
actionError?: string | null;
busyKey: string | null;
canViewStats?: boolean;
commentDraft: string;
comments: RequestComment[];
commentsLoading: boolean;
@ -37,6 +42,7 @@ type RequestDetailModalProps = {
export function RequestDetailModal({
actionError,
busyKey,
canViewStats = false,
commentDraft,
comments,
commentsLoading,
@ -55,6 +61,49 @@ export function RequestDetailModal({
onSetCommentDraft,
request,
}: RequestDetailModalProps) {
const [statsOpen, setStatsOpen] = useState(false);
const [statsLoading, setStatsLoading] = useState(false);
const [statsError, setStatsError] = useState<string | null>(null);
const [stats, setStats] = useState<RequestEmployeeStatistics | null>(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 (
<Modal
@ -145,6 +206,55 @@ export function RequestDetailModal({
)}
>
<div className="space-y-5 text-sm text-[var(--foreground)]">
{canViewStats ? (
<div className="app-surface overflow-hidden rounded-xl">
<button
type="button"
onClick={() => setStatsOpen((current) => !current)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--surface-secondary)]"
aria-expanded={statsOpen}
>
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--foreground)]">Статистика сотрудника</p>
<p className="app-text-muted mt-1 text-xs">
{displayUserName(author) || "Сотрудник"}: история заявлений и дней отсутствия
</p>
</div>
<ChevronDown
size={18}
className={`shrink-0 text-[var(--foreground-muted)] transition-transform ${statsOpen ? "rotate-180" : ""}`}
/>
</button>
{statsOpen ? (
<div className="border-t border-[var(--border-subtle)] px-4 py-4">
{statsLoading || (!stats && !statsError) ? (
<div className="app-text-muted flex items-center gap-2 text-sm">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-[var(--border-strong)] border-t-[var(--accent-primary)]" />
<span>Загрузка статистики...</span>
</div>
) : statsError ? (
<div className="app-feedback-danger rounded-xl px-3 py-2 text-sm">
{statsError}
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{statsItems.map((item) => (
<div
key={item.label}
className="rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-3"
>
<p className="app-text-muted text-xs">{item.label}</p>
<p className="mt-2 text-2xl font-semibold text-[var(--foreground)]">{item.value}</p>
</div>
))}
</div>
)}
</div>
) : null}
</div>
) : null}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
<div className="app-surface-muted rounded-xl p-4">
<div className="space-y-3">

View File

@ -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<RequestEmployeeStatistics> =>
request(`/api/v1/requests/${requestId}/employee-statistics/`),
createRequest: (data: Record<string, any>, saveAs?: 'draft' | 'submit') => formDataRequest('/api/v1/requests/', 'POST', data, saveAs),
updateRequest: (requestId: number, data: Record<string, any>, saveAs?: 'draft' | 'submit') => formDataRequest(`/api/v1/requests/${requestId}/`, 'PATCH', data, saveAs),
deleteRequest: (requestId: number): Promise<void> => request(`/api/v1/requests/${requestId}/`, { method: 'DELETE' }),

View File

@ -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")
);
}
/**
* Проверяет, может ли пользователь управлять оборудованием.
*

View File

@ -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;