Add employee request statistics panel
This commit is contained in:
parent
f538b95cf9
commit
824728019b
@ -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-сериализатором,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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' }),
|
||||
|
||||
@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, может ли пользователь управлять оборудованием.
|
||||
*
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user