Add request statistics panel to requests page

This commit is contained in:
lizerk 2026-04-20 12:40:04 +03:00
parent 0984ae3ef2
commit 8a65498b56
6 changed files with 338 additions and 153 deletions

View File

@ -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):
"""

View File

@ -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() {
/>
) : (
<>
<RequestStatisticsPanel
canView={canViewStats}
employees={h.employees}
/>
<RequestListControls
actions={requestControlsActions}
feedback={requestControlsFeedback}
@ -142,7 +147,6 @@ 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

@ -8,18 +8,15 @@ import {
type RequestAttachmentPreview,
} from "@/hooks/useRequestsPage";
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, useState } from "react";
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";
type RequestDetailModalProps = {
actionError?: string | null;
busyKey: string | null;
canViewStats?: boolean;
commentDraft: string;
comments: RequestComment[];
commentsLoading: boolean;
@ -42,7 +39,6 @@ type RequestDetailModalProps = {
export function RequestDetailModal({
actionError,
busyKey,
canViewStats = false,
commentDraft,
comments,
commentsLoading,
@ -61,49 +57,6 @@ 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, 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 (
<Modal
@ -203,55 +147,6 @@ 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

@ -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<number | null>(null);
const [period, setPeriod] = useState<StatsPeriod>("all");
const [stats, setStats] = useState<RequestEmployeeStatistics | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="app-surface mb-4 overflow-hidden rounded-2xl">
<button
type="button"
onClick={() => setOpen((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={open}
>
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--foreground)]">Статистика по заявлениям</p>
<p className="app-text-muted mt-1 text-xs">
Выбор сотрудника и периода для сводной статистики
</p>
</div>
<ChevronDown
size={18}
className={`shrink-0 text-[var(--foreground-muted)] transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
{open ? (
<div className="border-t border-[var(--border-subtle)] px-4 py-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<SearchableSelectSingle
label="Сотрудник"
items={employeeOptions}
selectedId={employeeId}
onSelect={(id) => {
setEmployeeId(id);
setStats(null);
setError(null);
}}
placeholder="Выберите сотрудника"
/>
<div className="flex flex-wrap gap-2">
{statsPeriods.map((periodOption) => {
const isActive = period === periodOption.value;
return (
<button
key={periodOption.value}
type="button"
onClick={() => {
if (period === periodOption.value) return;
setPeriod(periodOption.value);
setStats(null);
setError(null);
}}
className={isActive
? "app-action-primary rounded-lg px-3 py-2 text-xs font-medium"
: "app-action-secondary rounded-lg px-3 py-2 text-xs font-medium"
}
>
{periodOption.label}
</button>
);
})}
</div>
</div>
{!employeeId ? (
<p className="app-text-muted mt-4 text-sm">
Выберите сотрудника, чтобы посмотреть статистику.
</p>
) : loading || (!stats && !error) ? (
<div className="app-text-muted mt-4 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>
) : error ? (
<div className="app-feedback-danger mt-4 rounded-xl px-3 py-2 text-sm">
{error}
</div>
) : (
<div className="mt-4">
<p className="app-text-muted mb-3 text-xs">
{period === "all"
? "Период: за всё время"
: period === "year"
? "Период: текущий календарный год"
: "Период: текущий календарный месяц"}
</p>
<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>
)}
</div>
) : null}
</div>
);
}

View File

@ -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<RequestEmployeeStatistics> =>
request(`/api/v1/requests/${requestId}/employee-statistics/`),
getRequestEmployeeStatistics: (requestId: number, period: 'all' | 'year' | 'month' = 'all'): Promise<RequestEmployeeStatistics> =>
request(`/api/v1/requests/${requestId}/employee-statistics/?period=${period}`),
getEmployeeRequestStatistics: (employeeId: number, period: 'all' | 'year' | 'month' = 'all'): Promise<RequestEmployeeStatistics> =>
request(`/api/v1/requests/statistics/?employee_id=${employeeId}&period=${period}`),
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

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