Add request statistics panel to requests page
This commit is contained in:
parent
0984ae3ef2
commit
8a65498b56
@ -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):
|
||||
"""
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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">
|
||||
|
||||
181
frontend/src/components/requests/RequestStatisticsPanel.tsx
Normal file
181
frontend/src/components/requests/RequestStatisticsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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' }),
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user