Merge branch 'develop'
This commit is contained in:
commit
b84e4b16b4
@ -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-сериализатором,
|
||||
|
||||
@ -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:
|
||||
|
||||
2
frontend/next-env.d.ts
vendored
2
frontend/next-env.d.ts
vendored
@ -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.
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
{/* Мобильная панель уведомлений */}
|
||||
|
||||
@ -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>
|
||||
|
||||
314
frontend/src/components/requests/RequestStatisticsPanel.tsx
Normal file
314
frontend/src/components/requests/RequestStatisticsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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' }),
|
||||
|
||||
@ -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,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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user