Merge branch 'develop'

This commit is contained in:
lizerk 2026-04-20 13:03:05 +03:00
commit b84e4b16b4
11 changed files with 1030 additions and 155 deletions

View File

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

View File

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

View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.

View File

@ -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 ? (
<div className="app-feedback-danger rounded-2xl p-6 text-center"><p className="text-sm">{h.error}</p></div>
) : (
<section className="app-surface rounded-2xl p-4">
{h.swipeMode && h.pendingDecisionRequests.length > 0 ? (
<RequestSwipeModePanel
actionError={h.actionError}
onApprove={h.handleApprove}
onClose={() => h.setSwipeMode(false)}
onReject={h.handleReject}
requests={h.pendingDecisionRequests}
<>
<RequestStatisticsPanel
canView={canViewStats}
employees={h.employees}
/>
<section className="app-surface rounded-2xl p-4">
{h.swipeMode && h.pendingDecisionRequests.length > 0 ? (
<RequestSwipeModePanel
actionError={h.actionError}
onApprove={h.handleApprove}
onClose={() => h.setSwipeMode(false)}
onReject={h.handleReject}
requests={h.pendingDecisionRequests}
/>
) : (
<>
<RequestListControls
actions={requestControlsActions}
feedback={requestControlsFeedback}
state={requestControlsState}
/>
) : (
<>
<RequestListControls
actions={requestControlsActions}
feedback={requestControlsFeedback}
state={requestControlsState}
/>
<RequestListSection
busyKey={h.busyKey}
commentDrafts={h.commentDrafts}
commentsMap={h.commentsMap}
currentUserId={user?.id}
departmentNameMap={h.departmentNameMap}
expandedComments={h.expandedComments}
expandedRows={h.expandedRows}
handleAddComment={h.handleAddComment}
handleApprove={h.handleApprove}
handleCancel={h.handleCancel}
handleDelete={h.handleDelete}
handleDeleteComment={h.handleDeleteComment}
handleReject={h.handleReject}
isFinal={h.isFinal}
loadMoreRef={h.loadMoreRef}
loadingMore={h.loadingMore}
nextPage={h.nextPage}
openEdit={h.openEdit}
requestMenuOpenId={screen.requestMenuOpenId}
requestMenuRef={screen.requestMenuRef}
requests={h.requests}
setAttachmentPreview={h.setAttachmentPreview}
onSetCommentDraft={h.setCommentDraft}
setDetailsRequest={h.setDetailsRequest}
setRequestMenuOpenId={screen.setRequestMenuOpenId}
toggleComments={h.toggleComments}
toggleRow={h.toggleRow}
/>
</>
)}
</section>
<RequestListSection
busyKey={h.busyKey}
commentDrafts={h.commentDrafts}
commentsMap={h.commentsMap}
currentUserId={user?.id}
departmentNameMap={h.departmentNameMap}
expandedComments={h.expandedComments}
expandedRows={h.expandedRows}
handleAddComment={h.handleAddComment}
handleApprove={h.handleApprove}
handleCancel={h.handleCancel}
handleDelete={h.handleDelete}
handleDeleteComment={h.handleDeleteComment}
handleReject={h.handleReject}
isFinal={h.isFinal}
loadMoreRef={h.loadMoreRef}
loadingMore={h.loadingMore}
nextPage={h.nextPage}
openEdit={h.openEdit}
requestMenuOpenId={screen.requestMenuOpenId}
requestMenuRef={screen.requestMenuRef}
requests={h.requests}
setAttachmentPreview={h.setAttachmentPreview}
onSetCommentDraft={h.setCommentDraft}
setDetailsRequest={h.setDetailsRequest}
setRequestMenuOpenId={screen.setRequestMenuOpenId}
toggleComments={h.toggleComments}
toggleRow={h.toggleRow}
/>
</>
)}
</section>
</>
)}
<RequestDetailModal
actionError={h.actionError}
busyKey={h.busyKey}
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}
currentUserId={user?.id}
departmentNameMap={h.departmentNameMap}
isFinal={h.isFinal}
onAddComment={h.handleAddComment}
onApprove={h.handleApprove}
onCancel={h.handleCancel}
onClose={screen.closeDetailsRequest}
onDelete={h.handleDelete}
onDeleteComment={h.handleDeleteComment}
onEdit={(request) => {
screen.closeDetailsRequest();
h.openEdit(request);
}}
onPreviewAttachment={h.setAttachmentPreview}
onReject={h.handleReject}
onSetCommentDraft={h.setCommentDraft}
request={h.detailsRequest}
/>

View File

@ -177,12 +177,19 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa
</button>
<div className="hidden flex-1 items-center justify-center lg:flex">
<div className="relative w-full max-w-xl">
<form
className="relative w-full max-w-xl"
onSubmit={(e) => {
e.preventDefault();
submitSearch();
}}
>
<Search size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 app-text-muted" />
<input
type="text"
type="search"
value={searchQuery}
onChange={(e) => 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="Поиск"
/>
</div>
</form>
</div>
<div className="ml-auto hidden items-center gap-1 sm:gap-2 lg:flex">
@ -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" : ""}`}
>
<div className={`relative ${isBottomMobileNav ? "mx-auto max-w-6xl" : ""}`}>
<form
className={`relative ${isBottomMobileNav ? "mx-auto max-w-6xl" : ""}`}
onSubmit={(e) => {
e.preventDefault();
submitSearch();
setIsSearchOpen(false);
}}
>
<Search size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 app-text-muted" />
<input
ref={searchInputRef}
type="text"
type="search"
value={searchQuery}
onChange={(e) => 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="Поиск по сайту..."
/>
</div>
<button
type="submit"
className="app-action-primary absolute right-1.5 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full"
aria-label="Найти"
title="Найти"
>
<Search size={14} />
</button>
</form>
</div>
{/* Мобильная панель уведомлений */}

View File

@ -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<number, string>;
isFinal: (status?: string) => boolean;
onAddComment: (requestId: number) => void | Promise<void>;
onApprove: (requestId: number) => void | Promise<void>;
onCancel: (requestId: number) => void | Promise<void>;
onClose: () => void;
onDelete: (requestId: number) => void | Promise<void>;
onDeleteComment: (requestId: number, commentId: number) => void | Promise<void>;
onEdit: (request: Request) => void;
onPreviewAttachment: (preview: RequestAttachmentPreview) => void;
onReject: (requestId: number) => void | Promise<void>;
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 (
<Modal
@ -131,30 +147,97 @@ export function RequestDetailModal({
)}
>
<div className="space-y-5 text-sm text-[var(--foreground)]">
<div className="app-surface-muted rounded-xl p-4">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="app-badge app-badge-accent px-2.5 py-1 text-xs font-medium">
{typeLabel}
</span>
<span className={`app-status-pill ${status.className}`}>
{status.label}
</span>
</div>
<h2 className="app-text-wrap text-lg font-semibold text-[var(--foreground)]">
{request.display_title || request.title || "Без заголовка"}
</h2>
{(request.comment || request.description) ? (
<div className="app-text-wrap mt-3 whitespace-pre-wrap text-sm leading-relaxed text-[var(--foreground)]">
{request.comment || request.description}
<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">
<div className="flex flex-wrap items-center gap-2">
<span className="app-badge app-badge-accent px-2.5 py-1 text-xs font-medium">
{typeLabel}
</span>
<span className={`app-status-pill ${status.className}`}>
{status.label}
</span>
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Описание отсутствует</p>
)}
<h2 className="app-text-wrap text-lg font-semibold text-[var(--foreground)]">
{request.display_title || request.title || "Без заголовка"}
</h2>
{(request.comment || request.description) ? (
<div className="app-text-wrap mt-3 whitespace-pre-wrap text-sm leading-relaxed text-[var(--foreground)]">
{request.comment || request.description}
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Описание отсутствует</p>
)}
</div>
</div>
<div className="app-surface overflow-hidden rounded-xl p-4 lg:h-[16.25rem]">
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between gap-2">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Комментарии</p>
<span className="app-badge px-2.5 py-1 text-xs font-medium">
{commentCount}
</span>
</div>
{!canComment ? (
<p className="app-text-muted mt-3 text-sm leading-relaxed">
Комментарии будут доступны после отправки заявления.
</p>
) : (
<div className="mt-3 flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto overscroll-contain pr-1">
{commentsLoading ? (
<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>
) : comments.length > 0 ? (
comments.map((comment) => (
<div
key={comment.id}
className="rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2.5"
>
<div className="mb-1 flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-[var(--foreground)]">
{displayUserName(comment.author)}
</p>
<p className="app-text-muted text-xs">
{formatDateTime(comment.created_at) || formatDate(comment.created_at)}
</p>
</div>
{Boolean(comment.author?.id && currentUserId === comment.author.id) ? (
<CommentDeleteButton
disabled={busyKey === `comment-delete-${comment.id}`}
onClick={() => onDeleteComment(request.id, comment.id)}
/>
) : null}
</div>
<p className="app-text-wrap whitespace-pre-wrap text-sm leading-relaxed text-[var(--foreground)]">
{comment.text}
</p>
</div>
))
) : (
<p className="app-text-muted text-sm">Комментариев пока нет</p>
)}
</div>
<CommentComposer
disabled={busyKey === `comment-${request.id}`}
value={commentDraft}
onChange={(value) => onSetCommentDraft(request.id, value)}
onSubmit={() => onAddComment(request.id)}
placeholder="Добавить комментарий"
/>
</div>
)}
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Даты</p>
<div className="mt-3 space-y-2">
@ -207,81 +290,85 @@ export function RequestDetailModal({
</div>
</div>
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Отделы</p>
{(request.departments || []).length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{(request.departments || []).map((id) => (
<span key={id} className="app-badge px-3 py-1.5 text-sm font-medium">
{departmentNameMap.get(Number(id)) || `Отдел #${id}`}
</span>
))}
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Отделы не указаны</p>
)}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Отделы</p>
{(request.departments || []).length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{(request.departments || []).map((id) => (
<span key={id} className="app-badge px-3 py-1.5 text-sm font-medium">
{departmentNameMap.get(Number(id)) || `Отдел #${id}`}
</span>
))}
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Отделы не указаны</p>
)}
</div>
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Получатели</p>
{(request.recipients || []).length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{(request.recipients || []).map((recipient) => (
<RequestUserBadge
key={recipient.id}
person={recipient}
currentUserId={currentUserId}
large
/>
))}
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Получатели не указаны</p>
)}
</div>
</div>
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Получатели</p>
{(request.recipients || []).length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{(request.recipients || []).map((recipient) => (
<RequestUserBadge
key={recipient.id}
person={recipient}
currentUserId={currentUserId}
large
/>
))}
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Получатели не указаны</p>
)}
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">В копии</p>
{(request.cc_users || []).length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{(request.cc_users || []).map((ccUser) => (
<RequestUserBadge
key={ccUser.id}
person={ccUser}
currentUserId={currentUserId}
large
/>
))}
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Копия не указана</p>
)}
</div>
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">В копии</p>
{(request.cc_users || []).length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{(request.cc_users || []).map((ccUser) => (
<RequestUserBadge
key={ccUser.id}
person={ccUser}
currentUserId={currentUserId}
large
/>
))}
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Копия не указана</p>
)}
</div>
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Вложение</p>
{attachmentUrl ? (
<div className="mt-3 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => onPreviewAttachment({ url: attachmentUrl, name: attachmentName })}
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"
>
<Paperclip size={15} />
<span className="app-text-wrap">{attachmentName}</span>
</button>
<a
href={attachmentUrl}
target="_blank"
rel="noreferrer"
className="app-link-accent text-sm font-medium hover:underline"
>
Открыть в новой вкладке
</a>
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Вложение отсутствует</p>
)}
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Вложение</p>
{attachmentUrl ? (
<div className="mt-3 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => onPreviewAttachment({ url: attachmentUrl, name: attachmentName })}
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"
>
<Paperclip size={15} />
<span className="app-text-wrap">{attachmentName}</span>
</button>
<a
href={attachmentUrl}
target="_blank"
rel="noreferrer"
className="app-link-accent text-sm font-medium hover:underline"
>
Открыть в новой вкладке
</a>
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Вложение отсутствует</p>
)}
</div>
</div>
</div>
</Modal>

View File

@ -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<number | null>(null);
const [period, setPeriod] = useState<StatsPeriod>("all");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [stats, setStats] = useState<RequestEmployeeStatistics | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<RequestEmployeeStatistics>(
`/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 (
<div className="app-surface 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="app-surface-muted rounded-2xl p-4">
<div className="relative">
<Search
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 app-text-muted"
/>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Выберите сотрудника для статистики"
className="app-input w-full rounded-xl py-2.5 pl-10 pr-3 text-sm"
/>
</div>
<div className="mt-3 flex items-center justify-between px-1 text-xs">
<span className="app-text-muted">Найдено: {filteredEmployees.length}</span>
<span className="app-accent-text font-medium">Выбрано: {employeeId ? 1 : 0}</span>
</div>
<div className="app-surface mt-3 max-h-72 space-y-2 overflow-y-auto rounded-2xl p-2">
{filteredEmployees.length > 0 ? (
filteredEmployees.map((employee) => {
const isSelected = employee.id === employeeId;
const employeeName = displayUserName(employee);
return (
<button
key={employee.id}
type="button"
onClick={() => {
setEmployeeId((current) => current === employee.id ? null : employee.id);
setStats(null);
setError(null);
}}
className={`flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition ${
isSelected
? "app-selected"
: "app-surface-muted hover:bg-[var(--surface-tertiary)]"
}`}
>
{employee.avatar ? (
<img
src={resolveMediaUrl(employee.avatar)}
alt={employeeName}
className="app-avatar-frame h-10 w-10 shrink-0 rounded-full object-cover"
/>
) : (
<div className="app-avatar-fallback flex h-10 w-10 shrink-0 items-center justify-center rounded-full">
<span className="text-sm font-semibold">
{employee.first_name?.[0] || employee.last_name?.[0] || "?"}
</span>
</div>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-[var(--foreground)]">
{employeeName}
</div>
<div className="truncate text-xs text-[var(--muted-foreground)]">
{employee.email || "Почта не указана"}
</div>
</div>
<div
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full border transition ${
isSelected
? "border-[color:var(--accent-primary)] bg-[var(--accent-primary)] text-white"
: "border-[var(--border-strong)] text-transparent"
}`}
>
<div className="h-2.5 w-2.5 rounded-full bg-current" />
</div>
</button>
);
})
) : (
<p className="app-text-muted px-2 py-3 text-sm">Сотрудники не найдены</p>
)}
</div>
<div className="mt-4 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>
{period === "custom" ? (
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Дата начала</span>
<input
type="date"
value={dateFrom}
onChange={(e) => {
setDateFrom(e.target.value);
setStats(null);
setError(null);
}}
className="app-input w-full rounded-xl px-3 py-2.5 text-sm"
/>
</label>
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Дата окончания</span>
<input
type="date"
value={dateTo}
onChange={(e) => {
setDateTo(e.target.value);
setStats(null);
setError(null);
}}
className="app-input w-full rounded-xl px-3 py-2.5 text-sm"
/>
</label>
</div>
) : null}
</div>
{!employeeId ? (
<p className="app-text-muted mt-4 text-sm">
Выберите сотрудника, чтобы посмотреть статистику.
</p>
) : period === "custom" && (!dateFrom || !dateTo) ? (
<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"
? "Период: текущий календарный год"
: period === "month"
? "Период: текущий календарный месяц"
: `Период: ${stats?.date_from || dateFrom} — ${stats?.date_to || dateTo}`}
</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

@ -109,6 +109,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
const [employees, setEmployees] = useState<User[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [commentsMap, setCommentsMap] = useState<Record<number, RequestComment[]>>({});
const [commentsLoadingMap, setCommentsLoadingMap] = useState<Record<number, boolean>>({});
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({});
const [expandedComments, setExpandedComments] = useState<Record<number, boolean>>({});
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
@ -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,

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,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<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

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