improve requests workspace and notifications
This commit is contained in:
parent
c0092437e1
commit
2fec931bda
@ -13,6 +13,7 @@ from django.utils import timezone
|
||||
from requests_app.enums import DAY_OFF_REQUEST_TYPES, RequestStatus, RequestType
|
||||
from requests_app.models import Request as EmployeeRequest
|
||||
from requests_app.models import RequestSettings
|
||||
from requests_app.notifications.read_service import RequestNotificationReadService
|
||||
from communications import comments_helpers
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
|
||||
@ -286,7 +287,11 @@ class RequestViewSet(viewsets.ModelViewSet):
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
return [IsAuthenticated()]
|
||||
if self.action in {"retrieve", "employee_statistics"}:
|
||||
if self.action in {
|
||||
"retrieve",
|
||||
"employee_statistics",
|
||||
"mark_notifications_read",
|
||||
}:
|
||||
return [CanViewRequest()]
|
||||
if self.action == "destroy":
|
||||
return [IsRequestAuthor(), NotFinalOrStaff()]
|
||||
@ -339,7 +344,13 @@ class RequestViewSet(viewsets.ModelViewSet):
|
||||
) & ~Q(status=RequestStatus.DRAFT)
|
||||
|
||||
addressed_to_me = params.get("addressed_to_me") == "true"
|
||||
if addressed_to_me:
|
||||
decision_required = params.get("decision_required") == "true"
|
||||
if decision_required:
|
||||
scope = Q(
|
||||
recipients=user,
|
||||
status=RequestStatus.PENDING,
|
||||
)
|
||||
elif addressed_to_me:
|
||||
scope = incoming_scope
|
||||
elif want_mine:
|
||||
scope = Q(employee_id=user.id)
|
||||
@ -415,6 +426,24 @@ class RequestViewSet(viewsets.ModelViewSet):
|
||||
|
||||
return qs
|
||||
|
||||
def _get_scope_counts(self, user) -> dict[str, int]:
|
||||
"""Полные счётчики быстрых областей списка заявлений."""
|
||||
incoming_scope = (
|
||||
Q(recipients=user) | Q(cc_users=user)
|
||||
) & ~Q(status=RequestStatus.DRAFT)
|
||||
visible_scope = Q(employee_id=user.id) | incoming_scope
|
||||
base = EmployeeRequest.objects.filter(visible_scope).distinct()
|
||||
|
||||
return {
|
||||
"all": base.count(),
|
||||
"mine": base.filter(employee_id=user.id).count(),
|
||||
"addressed": base.filter(incoming_scope).count(),
|
||||
"decision": base.filter(
|
||||
recipients=user,
|
||||
status=RequestStatus.PENDING,
|
||||
).count(),
|
||||
}
|
||||
|
||||
def _attach_linked_task_payloads(
|
||||
self,
|
||||
requests: list[EmployeeRequest],
|
||||
@ -472,17 +501,20 @@ class RequestViewSet(viewsets.ModelViewSet):
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
scope_counts = self._get_scope_counts(request.user)
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
self._attach_linked_task_payloads(list(page))
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
response = self.get_paginated_response(serializer.data)
|
||||
response.data["scope_counts"] = scope_counts
|
||||
return response
|
||||
|
||||
requests = list(queryset)
|
||||
self._attach_linked_task_payloads(requests)
|
||||
serializer = self.get_serializer(requests, many=True)
|
||||
return Response(serializer.data)
|
||||
return Response({"results": serializer.data, "scope_counts": scope_counts})
|
||||
|
||||
# --- ВАЖНО: не ловить 404 на detail-экшенах
|
||||
# из-за урезанного get_queryset() ---
|
||||
@ -509,6 +541,7 @@ class RequestViewSet(viewsets.ModelViewSet):
|
||||
"cancel",
|
||||
"retrieve",
|
||||
"employee_statistics",
|
||||
"mark_notifications_read",
|
||||
}:
|
||||
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
|
||||
lookup_value = self.kwargs[lookup_url_kwarg]
|
||||
@ -521,6 +554,22 @@ class RequestViewSet(viewsets.ModelViewSet):
|
||||
|
||||
return super().get_object()
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="notifications/read")
|
||||
def mark_notifications_read(self, request, pk=None):
|
||||
"""Отметить уведомления текущего пользователя по заявлению."""
|
||||
employee_request = self.get_object()
|
||||
result = RequestNotificationReadService().mark_request_notifications_as_read(
|
||||
user=request.user,
|
||||
employee_request=employee_request,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"count": result.count,
|
||||
"notification_ids": result.notification_ids,
|
||||
}
|
||||
)
|
||||
|
||||
# ---------- CRUD ----------
|
||||
|
||||
def perform_create(self, serializer: RequestWriteSerializer) -> None:
|
||||
|
||||
@ -51,6 +51,11 @@ class UnreadSummaryProcurementRequestSerializer(serializers.Serializer):
|
||||
unread = serializers.IntegerField()
|
||||
|
||||
|
||||
class UnreadSummaryEmployeeRequestSerializer(serializers.Serializer):
|
||||
request_id = serializers.IntegerField()
|
||||
unread = serializers.IntegerField()
|
||||
|
||||
|
||||
class UnreadSummaryProcurementItemCommentSerializer(serializers.Serializer):
|
||||
item_id = serializers.IntegerField()
|
||||
unread = serializers.IntegerField()
|
||||
@ -69,6 +74,7 @@ class UnreadSummaryRegulationDepartmentSerializer(serializers.Serializer):
|
||||
class UnreadSummaryResponseSerializer(serializers.Serializer):
|
||||
total = serializers.IntegerField()
|
||||
verbs = UnreadSummaryVerbSerializer(many=True)
|
||||
employee_requests = UnreadSummaryEmployeeRequestSerializer(many=True)
|
||||
procurement_requests = UnreadSummaryProcurementRequestSerializer(many=True)
|
||||
procurement_item_comments = UnreadSummaryProcurementItemCommentSerializer(
|
||||
many=True,
|
||||
|
||||
@ -7,7 +7,7 @@ UNREAD_SUMMARY_TIMEOUT = 60
|
||||
|
||||
|
||||
def unread_summary_cache_key(user_id):
|
||||
return f"notifications:unread_summary:user:{user_id}"
|
||||
return f"notifications:unread_summary:v2:user:{user_id}"
|
||||
|
||||
|
||||
def _coerce_positive_int(value):
|
||||
@ -72,6 +72,52 @@ def _build_procurement_request_counts(user_id):
|
||||
]
|
||||
|
||||
|
||||
def _build_employee_request_counts(user_id):
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from requests_app.models import Request as EmployeeRequest
|
||||
|
||||
from .models import Notification
|
||||
|
||||
request_content_type_id = ContentType.objects.get_for_model(EmployeeRequest).id
|
||||
counts = {}
|
||||
notifications = Notification.objects.filter(
|
||||
recipient_id=user_id,
|
||||
unread=True,
|
||||
deleted=False,
|
||||
verb__startswith="request_",
|
||||
).only(
|
||||
"data",
|
||||
"target_content_type_id",
|
||||
"target_object_id",
|
||||
"action_object_content_type_id",
|
||||
"action_object_object_id",
|
||||
)
|
||||
|
||||
for notification in notifications:
|
||||
data = notification.data if isinstance(notification.data, dict) else {}
|
||||
request_id = _coerce_positive_int(data.get("request_id"))
|
||||
|
||||
if request_id is None and (
|
||||
notification.target_content_type_id == request_content_type_id
|
||||
):
|
||||
request_id = _coerce_positive_int(notification.target_object_id)
|
||||
|
||||
if request_id is None and (
|
||||
notification.action_object_content_type_id == request_content_type_id
|
||||
):
|
||||
request_id = _coerce_positive_int(
|
||||
notification.action_object_object_id,
|
||||
)
|
||||
|
||||
if request_id is not None:
|
||||
counts[request_id] = counts.get(request_id, 0) + 1
|
||||
|
||||
return [
|
||||
{"request_id": request_id, "unread": unread}
|
||||
for request_id, unread in sorted(counts.items())
|
||||
]
|
||||
|
||||
|
||||
def _build_procurement_item_comment_counts(user_id):
|
||||
from .models import Notification
|
||||
|
||||
@ -231,6 +277,7 @@ def build_unread_summary(user_id):
|
||||
return {
|
||||
"total": sum(item["unread"] for item in verbs),
|
||||
"verbs": verbs,
|
||||
"employee_requests": _build_employee_request_counts(user_id),
|
||||
"procurement_requests": _build_procurement_request_counts(user_id),
|
||||
"procurement_item_comments": _build_procurement_item_comment_counts(user_id),
|
||||
"task_boards": _build_task_board_counts(user_id),
|
||||
|
||||
73
backend/requests_app/notifications/read_service.py
Normal file
73
backend/requests_app/notifications/read_service.py
Normal file
@ -0,0 +1,73 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from notifications.cache import invalidate_unread_summary
|
||||
from notifications.models import Notification
|
||||
from notifications.realtime import send_notifications_read_all_event
|
||||
from requests_app.models import Request as EmployeeRequest
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestNotificationReadResult:
|
||||
notification_ids: list[int]
|
||||
count: int
|
||||
|
||||
|
||||
class RequestNotificationReadService:
|
||||
"""Отмечает прочитанными уведомления только выбранного заявления."""
|
||||
|
||||
def mark_request_notifications_as_read(
|
||||
self,
|
||||
*,
|
||||
user,
|
||||
employee_request: EmployeeRequest,
|
||||
) -> RequestNotificationReadResult:
|
||||
request_content_type = ContentType.objects.get_for_model(EmployeeRequest)
|
||||
request_id = employee_request.id
|
||||
request_id_text = str(request_id)
|
||||
|
||||
queryset = (
|
||||
Notification.objects.filter(
|
||||
recipient_id=user.id,
|
||||
unread=True,
|
||||
deleted=False,
|
||||
verb__startswith="request_",
|
||||
)
|
||||
.filter(
|
||||
Q(data__request_id=request_id)
|
||||
| Q(data__request_id=request_id_text)
|
||||
| Q(
|
||||
target_content_type=request_content_type,
|
||||
target_object_id=request_id_text,
|
||||
)
|
||||
| Q(
|
||||
action_object_content_type=request_content_type,
|
||||
action_object_object_id=request_id_text,
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
notification_ids = list(queryset.values_list("id", flat=True))
|
||||
|
||||
if not notification_ids:
|
||||
return RequestNotificationReadResult(notification_ids=[], count=0)
|
||||
|
||||
count = Notification.objects.filter(id__in=notification_ids).update(
|
||||
unread=False,
|
||||
timestamp_read=timezone.now(),
|
||||
)
|
||||
invalidate_unread_summary(user.id)
|
||||
|
||||
def notify_after_commit():
|
||||
invalidate_unread_summary(user.id)
|
||||
send_notifications_read_all_event(user.id, notification_ids)
|
||||
|
||||
transaction.on_commit(notify_after_commit)
|
||||
return RequestNotificationReadResult(
|
||||
notification_ids=notification_ids,
|
||||
count=count,
|
||||
)
|
||||
@ -45,6 +45,18 @@ class TestNotificationUnreadSummary:
|
||||
verb="chat_new_message",
|
||||
description="Сообщение",
|
||||
)
|
||||
Notification.objects.create(
|
||||
recipient=user,
|
||||
verb="request_new",
|
||||
description="Новое заявление",
|
||||
data={"request_id": 31},
|
||||
)
|
||||
Notification.objects.create(
|
||||
recipient=user,
|
||||
verb="request_comment",
|
||||
description="Комментарий к заявлению",
|
||||
data={"request_id": "31"},
|
||||
)
|
||||
Notification.objects.create(
|
||||
recipient=user,
|
||||
verb="task_assigned",
|
||||
@ -102,7 +114,7 @@ class TestNotificationUnreadSummary:
|
||||
response = api_client.get("/api/v1/notifications/summary/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["total"] == 10
|
||||
assert response.data["total"] == 12
|
||||
assert {
|
||||
item["verb"]: item["unread"]
|
||||
for item in response.data["verbs"]
|
||||
@ -110,12 +122,17 @@ class TestNotificationUnreadSummary:
|
||||
"procurement_department_request": 2,
|
||||
"procurement_item_commented": 1,
|
||||
"chat_new_message": 1,
|
||||
"request_new": 1,
|
||||
"request_comment": 1,
|
||||
"task_assigned": 1,
|
||||
"task_comment": 1,
|
||||
"task_board_member_added": 1,
|
||||
"regulation_ready": 2,
|
||||
"regulation_signed_all": 1,
|
||||
}
|
||||
assert response.data["employee_requests"] == [
|
||||
{"request_id": 31, "unread": 2},
|
||||
]
|
||||
assert response.data["procurement_requests"] == [
|
||||
{"request_id": 10, "unread": 2},
|
||||
{"request_id": 12, "unread": 1},
|
||||
|
||||
@ -47,6 +47,59 @@ def test_list_unauth_401(api_client: APIClient) -> None:
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_marks_only_opened_request_notifications_read(
|
||||
auth_client,
|
||||
regular_user: models.Model,
|
||||
make_request,
|
||||
) -> None:
|
||||
"""Открытие заявления читает уведомления только этого заявления."""
|
||||
employee_request = make_request(employee=regular_user)
|
||||
other_request = make_request(employee=regular_user)
|
||||
request_content_type = ContentType.objects.get_for_model(EmployeeRequest)
|
||||
|
||||
matching_by_data = Notification.objects.create(
|
||||
recipient=regular_user,
|
||||
verb="request_new",
|
||||
data={"request_id": employee_request.id},
|
||||
)
|
||||
matching_by_action_object = Notification.objects.create(
|
||||
recipient=regular_user,
|
||||
verb="request_comment",
|
||||
action_object_content_type=request_content_type,
|
||||
action_object_object_id=str(employee_request.id),
|
||||
data={},
|
||||
)
|
||||
other_request_notification = Notification.objects.create(
|
||||
recipient=regular_user,
|
||||
verb="request_status_changed",
|
||||
data={"request_id": other_request.id},
|
||||
)
|
||||
unrelated_notification = Notification.objects.create(
|
||||
recipient=regular_user,
|
||||
verb="chat_new_message",
|
||||
data={"request_id": employee_request.id},
|
||||
)
|
||||
|
||||
response = auth_client(regular_user).post(
|
||||
f"{API_BASE}{employee_request.id}/notifications/read/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 2
|
||||
assert sorted(response.data["notification_ids"]) == sorted(
|
||||
[matching_by_data.id, matching_by_action_object.id]
|
||||
)
|
||||
|
||||
matching_by_data.refresh_from_db()
|
||||
matching_by_action_object.refresh_from_db()
|
||||
other_request_notification.refresh_from_db()
|
||||
unrelated_notification.refresh_from_db()
|
||||
assert matching_by_data.unread is False
|
||||
assert matching_by_action_object.unread is False
|
||||
assert other_request_notification.unread is True
|
||||
assert unrelated_notification.unread is True
|
||||
|
||||
|
||||
def test_statistics_requires_privileged_access(
|
||||
auth_client, regular_user: models.Model, make_user
|
||||
) -> None:
|
||||
@ -227,6 +280,47 @@ def test_list_addressed_to_me_shows_only_recipient_and_cc_requests(
|
||||
assert cc_request.id in ids
|
||||
|
||||
|
||||
def test_list_returns_scope_counts_and_filters_decisions(
|
||||
auth_client, regular_user: models.Model, make_user, make_request
|
||||
) -> None:
|
||||
foreign_user = make_user(email="scope-counts-owner@example.com")
|
||||
own_request = make_request(employee=regular_user)
|
||||
decision_request = make_request(
|
||||
employee=foreign_user,
|
||||
status=RequestStatus.PENDING,
|
||||
)
|
||||
copied_request = make_request(
|
||||
employee=foreign_user,
|
||||
status=RequestStatus.APPROVED,
|
||||
approver=foreign_user,
|
||||
)
|
||||
hidden_request = make_request(employee=foreign_user)
|
||||
|
||||
decision_request.recipients.add(regular_user)
|
||||
copied_request.cc_users.add(regular_user)
|
||||
|
||||
client = auth_client(regular_user)
|
||||
response = client.get(API_BASE)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["scope_counts"] == {
|
||||
"all": 3,
|
||||
"mine": 1,
|
||||
"addressed": 2,
|
||||
"decision": 1,
|
||||
}
|
||||
|
||||
decision_response = client.get(f"{API_BASE}?decision_required=true")
|
||||
assert decision_response.status_code == 200
|
||||
decision_ids = {
|
||||
item["id"] for item in _results(decision_response.json())
|
||||
}
|
||||
assert decision_ids == {decision_request.id}
|
||||
assert own_request.id not in decision_ids
|
||||
assert copied_request.id not in decision_ids
|
||||
assert hidden_request.id not in decision_ids
|
||||
|
||||
|
||||
def test_list_period_filter_matches_overlap_and_excludes_missing_period(
|
||||
auth_client, regular_user: models.Model, make_request
|
||||
) -> None:
|
||||
|
||||
@ -1217,12 +1217,12 @@ export default function ProcurementPage() {
|
||||
|
||||
{/* ══════════ Wide master-detail workspace ══════════ */}
|
||||
<div className={`${desktopWideMode ? "hidden lg:grid" : "hidden"} min-h-0 flex-1 grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-3`}>
|
||||
<aside className="app-surface-muted relative isolate min-h-0 overflow-y-auto rounded-xl border border-[var(--border-subtle)] px-2 pb-2" aria-label="Список заявок на закупку">
|
||||
<div className="app-divider sticky top-0 z-30 mb-2 flex items-center justify-between gap-2 border-b bg-[var(--surface-secondary)] px-2 py-2 shadow-sm">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-[var(--muted-foreground)]">Заявки</span>
|
||||
<span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{filteredRequests.length}</span>
|
||||
<aside className="app-surface-muted min-h-0 overflow-y-auto rounded-xl border border-[var(--border-subtle)]" aria-label="Список заявок на закупку">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2.5">
|
||||
<span className="app-card-caption">Заявки</span>
|
||||
<span className="app-counter inline-flex h-5 min-w-5 items-center justify-center px-1 text-[10px] font-bold">{filteredRequests.length}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 p-2">
|
||||
{filteredRequests.length === 0 ? (
|
||||
<div className="px-3 py-10 text-center">
|
||||
<ShoppingCart size={22} className="app-text-muted mx-auto mb-2" />
|
||||
|
||||
@ -4,17 +4,21 @@ import { AppShell } from "../../components/AppShell";
|
||||
import { RequestAttachmentPreviewModal } from "@/components/requests/RequestAttachmentPreviewModal";
|
||||
import { RequestComposeModal } from "@/components/requests/RequestComposeModal";
|
||||
import { RequestDetailModal } from "@/components/requests/RequestDetailModal";
|
||||
import { RequestDetailPanel } from "@/components/requests/RequestDetailPanel";
|
||||
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 { RequestTaskLinks } from "@/components/requests/RequestTaskLinks";
|
||||
import { RequestWideListItem } from "@/components/requests/RequestWideListItem";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { Suspense, useState } from "react";
|
||||
import { Suspense, useState, type RefObject } from "react";
|
||||
import { useRequestsPage } from "@/hooks/useRequestsPage";
|
||||
import { useRequestsPageScreen } from "@/hooks/useRequestsPageScreen";
|
||||
import { useRequestsDesktopWideMode } from "@/hooks/useRequestsDesktopWideMode";
|
||||
import { canViewRequestStatistics } from "@/lib/permissions";
|
||||
import type { Request } from "@/types/api";
|
||||
import { ChartNoAxesCombined, FileSignature } from "lucide-react";
|
||||
|
||||
export default function RequestsPage() {
|
||||
return (
|
||||
@ -35,10 +39,26 @@ function RequestsPageFallback() {
|
||||
);
|
||||
}
|
||||
|
||||
function RequestWideListLoader({
|
||||
loadMoreRef,
|
||||
loading,
|
||||
}: {
|
||||
loadMoreRef: RefObject<HTMLDivElement | null>;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div ref={loadMoreRef} className="flex min-h-10 items-center justify-center py-2">
|
||||
{loading ? <span className="app-text-muted text-xs">Загрузка...</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestsPageContent() {
|
||||
const { user } = useUser();
|
||||
const canViewStats = canViewRequestStatistics(user);
|
||||
const [taskLinkRequest, setTaskLinkRequest] = useState<Request | null>(null);
|
||||
const [wideSidebarView, setWideSidebarView] = useState<"requests" | "statistics">("requests");
|
||||
const [desktopWideMode, changeDesktopWideMode, desktopWideLayout] = useRequestsDesktopWideMode(user?.id);
|
||||
const h = useRequestsPage(user?.id);
|
||||
const screen = useRequestsPageScreen({
|
||||
detailsRequestId: h.detailsRequest?.id,
|
||||
@ -78,13 +98,17 @@ function RequestsPageContent() {
|
||||
periodFromFilter: h.periodFromFilter,
|
||||
periodToFilter: h.periodToFilter,
|
||||
search: h.search,
|
||||
scopeCounts: h.scopeCounts,
|
||||
statusFilter: h.statusFilter,
|
||||
typeFilter: h.typeFilter,
|
||||
view: h.view,
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<AppShell
|
||||
desktopWideMode={desktopWideMode}
|
||||
onDesktopWideModeChange={changeDesktopWideMode}
|
||||
>
|
||||
{h.loading ? (
|
||||
<div className="app-surface rounded-2xl p-8 text-center">
|
||||
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-[var(--border-strong)] border-t-[var(--accent-primary)]" />
|
||||
@ -94,11 +118,13 @@ function RequestsPageContent() {
|
||||
<div className="app-feedback-danger rounded-2xl p-6 text-center"><p className="text-sm">{h.error}</p></div>
|
||||
) : (
|
||||
<>
|
||||
<RequestStatisticsPanel
|
||||
canView={canViewStats}
|
||||
employees={h.employees}
|
||||
/>
|
||||
<section className="app-surface rounded-2xl p-4">
|
||||
{!desktopWideLayout ? (
|
||||
<RequestStatisticsPanel
|
||||
canView={canViewStats}
|
||||
employees={h.employees}
|
||||
/>
|
||||
) : null}
|
||||
<section className={`app-surface rounded-2xl p-4 ${desktopWideMode ? "lg:flex lg:min-h-0 lg:flex-1 lg:flex-col lg:overflow-hidden" : ""}`}>
|
||||
{h.swipeMode && h.pendingDecisionRequests.length > 0 ? (
|
||||
<RequestSwipeModePanel
|
||||
actionError={h.actionError}
|
||||
@ -111,39 +137,143 @@ function RequestsPageContent() {
|
||||
<>
|
||||
<RequestListControls
|
||||
actions={requestControlsActions}
|
||||
desktopWideMode={desktopWideMode}
|
||||
feedback={requestControlsFeedback}
|
||||
onToggleDesktopWideMode={() => changeDesktopWideMode(!desktopWideMode)}
|
||||
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}
|
||||
onLinkTask={setTaskLinkRequest}
|
||||
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}
|
||||
/>
|
||||
{desktopWideLayout ? (
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-3">
|
||||
<aside className="app-surface-muted min-h-0 overflow-y-auto rounded-xl border border-[var(--border-subtle)]" aria-label="Заявления и статистика">
|
||||
<div className="sticky top-0 z-10 border-b border-[var(--border-subtle)] bg-[var(--surface-primary)] p-2">
|
||||
<div className={`grid ${canViewStats ? "grid-cols-2" : "grid-cols-1"} gap-1 rounded-lg bg-[var(--surface-secondary)] p-1`} role="tablist" aria-label="Содержимое левой панели">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={wideSidebarView === "requests"}
|
||||
onClick={() => setWideSidebarView("requests")}
|
||||
className={`${wideSidebarView === "requests" ? "app-selected" : "app-action-ghost"} flex min-w-0 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium`}
|
||||
>
|
||||
<FileSignature size={13} />
|
||||
<span className="truncate">Заявления</span>
|
||||
<span className="app-counter inline-flex h-4 min-w-4 shrink-0 items-center justify-center px-1 text-[9px] font-bold">
|
||||
{h.requests.length}
|
||||
</span>
|
||||
</button>
|
||||
{canViewStats ? (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={wideSidebarView === "statistics"}
|
||||
onClick={() => setWideSidebarView("statistics")}
|
||||
className={`${wideSidebarView === "statistics" ? "app-selected" : "app-action-ghost"} flex min-w-0 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium`}
|
||||
>
|
||||
<ChartNoAxesCombined size={13} />
|
||||
<span className="truncate">Статистика</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{wideSidebarView === "statistics" && canViewStats ? (
|
||||
<RequestStatisticsPanel
|
||||
canView={canViewStats}
|
||||
employees={h.employees}
|
||||
embedded
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2 p-2">
|
||||
{h.requests.length > 0 ? h.requests.map((request) => (
|
||||
<RequestWideListItem
|
||||
key={request.id}
|
||||
active={h.detailsRequest?.id === request.id}
|
||||
request={request}
|
||||
onOpen={screen.openDetailsRequest}
|
||||
unreadNotificationCount={h.unreadEmployeeRequestCounts[request.id] || 0}
|
||||
/>
|
||||
)) : (
|
||||
<div className="p-6 text-center">
|
||||
<FileSignature size={22} className="app-text-muted mx-auto mb-2" />
|
||||
<p className="app-text-muted text-sm">Заявления не найдены</p>
|
||||
</div>
|
||||
)}
|
||||
{h.nextPage ? (
|
||||
<RequestWideListLoader
|
||||
loadMoreRef={h.loadMoreRef}
|
||||
loading={h.loadingMore}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="app-surface-muted min-h-0 overflow-y-auto rounded-xl border border-[var(--border-subtle)] p-4" aria-label="Просмотр выбранного заявления">
|
||||
{h.detailsRequest ? (
|
||||
<RequestDetailPanel
|
||||
actionError={h.actionError}
|
||||
busyKey={h.busyKey}
|
||||
commentDraft={h.commentDrafts[h.detailsRequest.id] || ""}
|
||||
comments={h.commentsMap[h.detailsRequest.id] || []}
|
||||
commentsLoading={Boolean(h.commentsLoadingMap[h.detailsRequest.id])}
|
||||
currentUserId={user?.id}
|
||||
departmentNameMap={h.departmentNameMap}
|
||||
isFinal={h.isFinal}
|
||||
onAddComment={h.handleAddComment}
|
||||
onApprove={h.handleApprove}
|
||||
onCancel={h.handleCancel}
|
||||
onDelete={h.handleDelete}
|
||||
onDeleteComment={h.handleDeleteComment}
|
||||
onEdit={(request) => {
|
||||
screen.closeDetailsRequest();
|
||||
h.openEdit(request);
|
||||
}}
|
||||
onLinkTask={setTaskLinkRequest}
|
||||
onPreviewAttachment={h.setAttachmentPreview}
|
||||
onReject={h.handleReject}
|
||||
onSetCommentDraft={h.setCommentDraft}
|
||||
request={h.detailsRequest}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full min-h-64 flex-col items-center justify-center text-center">
|
||||
<FileSignature size={28} className="app-text-muted mb-3" />
|
||||
<p className="text-sm font-medium text-[var(--foreground)]">Выберите заявление</p>
|
||||
<p className="app-text-muted mt-1 text-xs">Полная информация появится в этой области.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<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}
|
||||
onLinkTask={setTaskLinkRequest}
|
||||
openEdit={h.openEdit}
|
||||
requestMenuOpenId={screen.requestMenuOpenId}
|
||||
requestMenuRef={screen.requestMenuRef}
|
||||
requests={h.requests}
|
||||
setAttachmentPreview={h.setAttachmentPreview}
|
||||
onSetCommentDraft={h.setCommentDraft}
|
||||
openDetailsRequest={screen.openDetailsRequest}
|
||||
setRequestMenuOpenId={screen.setRequestMenuOpenId}
|
||||
toggleComments={h.toggleComments}
|
||||
toggleRow={h.toggleRow}
|
||||
unreadNotificationCounts={h.unreadEmployeeRequestCounts}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
@ -173,7 +303,7 @@ function RequestsPageContent() {
|
||||
onPreviewAttachment={h.setAttachmentPreview}
|
||||
onReject={h.handleReject}
|
||||
onSetCommentDraft={h.setCommentDraft}
|
||||
request={h.detailsRequest}
|
||||
request={desktopWideLayout ? null : h.detailsRequest}
|
||||
/>
|
||||
|
||||
{h.isModalOpen && (
|
||||
|
||||
@ -61,7 +61,7 @@ const navItems = [
|
||||
{ href: "/employees", label: "Сотрудники", icon: Users },
|
||||
{ href: "/departments", label: "Отделы", icon: Building2 },
|
||||
{ href: "/attendance", label: "Посещаемость", icon: CalendarCheck },
|
||||
{ href: "/requests", label: "Заявления", icon: FileSignature, category: NAV_NOTIFICATION_CATEGORIES.requests },
|
||||
{ href: "/requests", label: "Заявления", icon: FileSignature, category: NAV_NOTIFICATION_CATEGORIES.requests, autoReadOnNavigate: false },
|
||||
{ href: "/guests", label: "Гости", icon: UserRoundPlus, category: NAV_NOTIFICATION_CATEGORIES.guests },
|
||||
// { href: "/equipment", label: "Оборудование", icon: Monitor },
|
||||
{ href: "/procurement", label: "Закупки", icon: ShoppingCart, category: NAV_NOTIFICATION_CATEGORIES.procurement, autoReadOnNavigate: false },
|
||||
|
||||
330
frontend/src/components/requests/RequestDetailPanel.tsx
Normal file
330
frontend/src/components/requests/RequestDetailPanel.tsx
Normal file
@ -0,0 +1,330 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
defaultStatusMeta,
|
||||
formatRequestPeriod,
|
||||
requestTypeLabels,
|
||||
statusMeta,
|
||||
type RequestAttachmentPreview,
|
||||
} from "@/hooks/useRequestsPage";
|
||||
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
|
||||
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
|
||||
import { displayUserName, formatDate, formatDateTime } from "@/lib/shared";
|
||||
import type { Request, RequestComment } from "@/types/api";
|
||||
import {
|
||||
Ban,
|
||||
Link2,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { getRequestActionState } from "./requestActions";
|
||||
import { RequestUserBadge } from "./RequestUserBadge";
|
||||
|
||||
type RequestDetailPanelProps = {
|
||||
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>;
|
||||
onDelete: (requestId: number) => void | Promise<void>;
|
||||
onDeleteComment: (requestId: number, commentId: number) => void | Promise<void>;
|
||||
onEdit: (request: Request) => void;
|
||||
onLinkTask: (request: Request) => void;
|
||||
onPreviewAttachment: (preview: RequestAttachmentPreview) => void;
|
||||
onReject: (requestId: number) => void | Promise<void>;
|
||||
onSetCommentDraft: (requestId: number, value: string) => void;
|
||||
request: Request;
|
||||
};
|
||||
|
||||
const newestCommentsFirst = (comments: RequestComment[]) => (
|
||||
[...comments].sort((left, right) => (
|
||||
new Date(right.created_at).getTime() - new Date(left.created_at).getTime()
|
||||
))
|
||||
);
|
||||
|
||||
export function RequestDetailPanel({
|
||||
actionError,
|
||||
busyKey,
|
||||
commentDraft,
|
||||
comments,
|
||||
commentsLoading,
|
||||
currentUserId,
|
||||
departmentNameMap,
|
||||
isFinal,
|
||||
onAddComment,
|
||||
onApprove,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onDeleteComment,
|
||||
onEdit,
|
||||
onLinkTask,
|
||||
onPreviewAttachment,
|
||||
onReject,
|
||||
onSetCommentDraft,
|
||||
request,
|
||||
}: RequestDetailPanelProps) {
|
||||
const author = request.employee || request.created_by;
|
||||
const approver = request.approver || request.assigned_to;
|
||||
const actionState = getRequestActionState(request, currentUserId, isFinal);
|
||||
const { canCancel, canComment, canDelete, canEdit, canProcess } = actionState;
|
||||
const status = statusMeta[String(request.status || "").toLowerCase()] ?? defaultStatusMeta;
|
||||
const typeKey = String(request.type || request.request_type || "").toLowerCase();
|
||||
const typeLabel = requestTypeLabels[typeKey] || String(request.type || request.request_type || "Другое");
|
||||
const attachmentUrl = request.attachment_url || request.attachment || "";
|
||||
const attachmentName = attachmentUrl
|
||||
? decodeURIComponent(attachmentUrl.split("/").pop() || "Вложение")
|
||||
: "";
|
||||
const commentCount = request.comments_count ?? comments.length;
|
||||
const linkedTasks = request.linked_tasks || [];
|
||||
const hasActions = canProcess || canCancel || canEdit || canDelete;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-sm text-[var(--foreground)]">
|
||||
<section className="app-surface rounded-xl p-4">
|
||||
<div className="flex min-w-0 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>
|
||||
<h2 className="app-text-wrap min-w-0 flex-[1_1_20rem] text-lg font-semibold">
|
||||
{request.display_title || request.title || "Без заголовка"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{(request.comment || request.description) ? (
|
||||
<p className="app-text-wrap mt-3 whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{request.comment || request.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="app-text-muted mt-3 text-sm">Описание отсутствует</p>
|
||||
)}
|
||||
|
||||
{actionError ? (
|
||||
<div className="app-feedback-danger mt-3 rounded-xl px-3 py-2 text-sm">
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasActions ? (
|
||||
<div className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-[var(--border-subtle)] pt-3">
|
||||
{canProcess ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onApprove(request.id)}
|
||||
disabled={busyKey === `approve-${request.id}`}
|
||||
className="app-action-approve inline-flex h-9 items-center gap-2 rounded-lg px-3 text-xs font-medium disabled:opacity-60"
|
||||
>
|
||||
<ThumbsUp size={14} className="text-emerald-500" />
|
||||
<span className="text-emerald-500">Одобрить</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onReject(request.id)}
|
||||
disabled={busyKey === `reject-${request.id}`}
|
||||
className="app-action-reject inline-flex h-9 items-center gap-2 rounded-lg px-3 text-xs font-medium disabled:opacity-60"
|
||||
>
|
||||
<ThumbsDown size={14} /> Отклонить
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{canCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onCancel(request.id)}
|
||||
disabled={busyKey === `cancel-${request.id}`}
|
||||
className="app-action-secondary inline-flex h-9 items-center gap-2 rounded-lg px-3 text-xs font-medium disabled:opacity-60"
|
||||
>
|
||||
<Ban size={14} /> Отменить
|
||||
</button>
|
||||
) : null}
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(request)}
|
||||
className="app-action-secondary inline-flex h-9 items-center gap-2 rounded-lg px-3 text-xs font-medium"
|
||||
>
|
||||
<Pencil size={14} /> Редактировать
|
||||
</button>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onDelete(request.id)}
|
||||
disabled={busyKey === `delete-${request.id}`}
|
||||
className="app-action-danger inline-flex h-9 items-center gap-2 rounded-lg px-3 text-xs font-medium disabled:opacity-60"
|
||||
>
|
||||
<Trash2 size={14} /> Удалить
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<section className="app-surface rounded-xl p-4">
|
||||
<p className="app-card-caption">Даты</p>
|
||||
<dl className="mt-3 grid gap-3 text-xs sm:grid-cols-3 xl:grid-cols-1">
|
||||
<div>
|
||||
<dt className="app-text-muted">{typeKey === "short_day_off" ? "Время" : "Период"}</dt>
|
||||
<dd className="mt-1 font-medium">{formatRequestPeriod(request)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="app-text-muted">Создано</dt>
|
||||
<dd className="mt-1">{formatDateTime(request.created_at) || "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="app-text-muted">Обновлено</dt>
|
||||
<dd className="mt-1">{formatDateTime(request.updated_at) || "—"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="app-surface rounded-xl p-4">
|
||||
<p className="app-card-caption">Участники</p>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<p className="app-text-muted text-xs">Автор</p>
|
||||
<div className="mt-1.5">{author ? <RequestUserBadge person={author} currentUserId={currentUserId} large /> : "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="app-text-muted text-xs">Принял решение</p>
|
||||
<div className="mt-1.5">{approver ? <RequestUserBadge person={approver} currentUserId={currentUserId} large /> : <span className="app-text-muted">—</span>}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="app-surface rounded-xl p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="app-card-caption">Связанные задачи</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLinkTask(request)}
|
||||
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium"
|
||||
>
|
||||
<Link2 size={13} /> Связать
|
||||
</button>
|
||||
</div>
|
||||
{linkedTasks.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{linkedTasks.map((task) => (
|
||||
<TaskLinkPill key={task.link_id || task.id} task={task} maxTitleClassName="max-w-56" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="app-text-muted mt-3 text-xs">Связанных задач нет</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<section className="app-surface rounded-xl p-4">
|
||||
<p className="app-card-caption">Отделы</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{(request.departments || []).length > 0 ? (request.departments || []).map((id) => (
|
||||
<span key={id} className="app-badge px-3 py-1.5 text-xs font-medium">
|
||||
{departmentNameMap.get(Number(id)) || `Отдел #${id}`}
|
||||
</span>
|
||||
)) : <span className="app-text-muted text-xs">Не указаны</span>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="app-surface rounded-xl p-4">
|
||||
<p className="app-card-caption">Получатели</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{(request.recipients || []).length > 0 ? (request.recipients || []).map((recipient) => (
|
||||
<RequestUserBadge key={recipient.id} person={recipient} currentUserId={currentUserId} />
|
||||
)) : <span className="app-text-muted text-xs">Не указаны</span>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="app-surface rounded-xl p-4">
|
||||
<p className="app-card-caption">В копии</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{(request.cc_users || []).length > 0 ? (request.cc_users || []).map((ccUser) => (
|
||||
<RequestUserBadge key={ccUser.id} person={ccUser} currentUserId={currentUserId} />
|
||||
)) : <span className="app-text-muted text-xs">Не указаны</span>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="app-surface rounded-xl p-4">
|
||||
<p className="app-card-caption">Вложение</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 min-w-0 items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
<Paperclip size={15} className="shrink-0" />
|
||||
<span className="truncate">{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-xs">Вложение отсутствует</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="app-surface rounded-xl p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="app-card-caption">Комментарии</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">Комментарии будут доступны после отправки заявления.</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-3">
|
||||
<CommentComposer
|
||||
disabled={busyKey === `comment-${request.id}`}
|
||||
value={commentDraft}
|
||||
onChange={(value) => onSetCommentDraft(request.id, value)}
|
||||
onSubmit={() => onAddComment(request.id)}
|
||||
placeholder="Добавить комментарий"
|
||||
/>
|
||||
{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)]" />
|
||||
Загрузка комментариев...
|
||||
</div>
|
||||
) : comments.length > 0 ? (
|
||||
<div className="grid gap-2 2xl:grid-cols-2">
|
||||
{newestCommentsFirst(comments).map((comment) => (
|
||||
<div key={comment.id} className="app-surface-muted rounded-lg border border-[var(--border-subtle)] 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">{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">{comment.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="app-text-muted text-sm">Комментариев пока нет</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,13 @@
|
||||
import { orderingOptions, requestTypeLabels, statusMeta } from "@/hooks/useRequestsPage";
|
||||
import {
|
||||
orderingOptions,
|
||||
requestTypeLabels,
|
||||
statusMeta,
|
||||
type RequestQuickScope,
|
||||
type RequestScopeCounts,
|
||||
} from "@/hooks/useRequestsPage";
|
||||
import { displayUserName } from "@/lib/shared";
|
||||
import type { User } from "@/types/api";
|
||||
import { ArrowUpDown, ChevronDown, Filter, Plus, Search, Zap } from "lucide-react";
|
||||
import { ArrowUpDown, ChevronDown, Filter, Maximize2, Minimize2, Plus, Search, Zap } from "lucide-react";
|
||||
|
||||
type RequestListControlsState = {
|
||||
activeFiltersCount: number;
|
||||
@ -16,9 +22,10 @@ type RequestListControlsState = {
|
||||
periodFromFilter: string;
|
||||
periodToFilter: string;
|
||||
search: string;
|
||||
scopeCounts: RequestScopeCounts;
|
||||
statusFilter: string;
|
||||
typeFilter: string;
|
||||
view: "" | "mine" | "addressed";
|
||||
view: RequestQuickScope;
|
||||
};
|
||||
|
||||
type RequestListControlsFeedback = {
|
||||
@ -39,21 +46,32 @@ type RequestListControlsActions = {
|
||||
setSearch: (value: string) => void;
|
||||
setStatusFilter: (value: string) => void;
|
||||
setTypeFilter: (value: string) => void;
|
||||
setView: (value: "" | "mine" | "addressed") => void;
|
||||
setView: (value: RequestQuickScope) => void;
|
||||
toggleFilters: () => void;
|
||||
};
|
||||
|
||||
type RequestListControlsProps = {
|
||||
actions: RequestListControlsActions;
|
||||
desktopWideMode?: boolean;
|
||||
feedback: RequestListControlsFeedback;
|
||||
onToggleDesktopWideMode?: () => void;
|
||||
state: RequestListControlsState;
|
||||
};
|
||||
|
||||
export function RequestListControls({
|
||||
actions,
|
||||
desktopWideMode = false,
|
||||
feedback,
|
||||
onToggleDesktopWideMode,
|
||||
state,
|
||||
}: RequestListControlsProps) {
|
||||
const quickScopes: { value: RequestQuickScope; label: string; count: number }[] = [
|
||||
{ value: "", label: "Все", count: state.scopeCounts.all },
|
||||
{ value: "mine", label: "Мои", count: state.scopeCounts.mine },
|
||||
{ value: "addressed", label: "Адресованные мне", count: state.scopeCounts.addressed },
|
||||
{ value: "decision", label: "Требуют решения", count: state.scopeCounts.decision },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
@ -70,13 +88,27 @@ export function RequestListControls({
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.openCreate}
|
||||
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
<Plus size={14} /> Создать заявление
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{onToggleDesktopWideMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleDesktopWideMode}
|
||||
className="app-action-ghost hidden h-9 w-9 items-center justify-center rounded-lg lg:inline-flex"
|
||||
title={desktopWideMode ? "Вернуть обычный вид" : "Развернуть заявления"}
|
||||
aria-label={desktopWideMode ? "Вернуть обычный вид" : "Развернуть заявления"}
|
||||
aria-pressed={desktopWideMode}
|
||||
>
|
||||
{desktopWideMode ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.openCreate}
|
||||
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
<Plus size={14} /> Создать заявление
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{feedback.actionError ? (
|
||||
@ -129,16 +161,48 @@ export function RequestListControls({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{quickScopes.map((scope) => {
|
||||
const active = state.view === scope.value;
|
||||
return (
|
||||
<button
|
||||
key={scope.value || "all"}
|
||||
type="button"
|
||||
onClick={() => actions.setView(scope.value)}
|
||||
className={`${active ? "app-pill-active" : "app-pill"} inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition`}
|
||||
>
|
||||
<span>{scope.label}</span>
|
||||
<span className={`${active ? "app-pill-count-active" : "app-pill-count"} app-badge px-1.5 py-0.5 text-[10px] font-bold`}>
|
||||
{scope.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{state.filtersOpen ? (
|
||||
<div className="app-surface-muted mb-4 flex flex-col gap-2 rounded-xl p-3">
|
||||
<div className="app-surface-muted mb-4 grid grid-cols-1 gap-2 rounded-xl p-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="flex min-h-7 items-center justify-between gap-3 sm:col-span-2 xl:col-span-4">
|
||||
<span className="app-text-muted text-xs font-semibold uppercase tracking-wide">Фильтры</span>
|
||||
{state.hasActiveFilters ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.clearFilters}
|
||||
className="app-link-accent text-xs font-medium"
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<select
|
||||
value={state.view}
|
||||
onChange={(event) => actions.setView(event.target.value as "" | "mine" | "addressed")}
|
||||
onChange={(event) => actions.setView(event.target.value as RequestQuickScope)}
|
||||
className="app-select rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Все заявления</option>
|
||||
<option value="mine">Мои заявления</option>
|
||||
<option value="addressed">Адресованные мне</option>
|
||||
<option value="decision">Требуют решения</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
@ -180,51 +244,42 @@ export function RequestListControls({
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="min-w-0 space-y-1.5 sm:col-span-2 xl:col-span-2">
|
||||
<label className="app-text-muted px-1 text-xs font-medium">Дата создания</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="grid min-w-0 grid-cols-2 gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={state.createdFromFilter}
|
||||
onChange={(event) => actions.setCreatedFromFilter(event.target.value)}
|
||||
className="app-input flex-1 rounded-lg px-3 py-2 text-sm"
|
||||
className="app-input min-w-0 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={state.createdToFilter}
|
||||
onChange={(event) => actions.setCreatedToFilter(event.target.value)}
|
||||
className="app-input flex-1 rounded-lg px-3 py-2 text-sm"
|
||||
className="app-input min-w-0 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="min-w-0 space-y-1.5 sm:col-span-2 xl:col-span-2">
|
||||
<label className="app-text-muted px-1 text-xs font-medium">Период заявления</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="grid min-w-0 grid-cols-2 gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={state.periodFromFilter}
|
||||
onChange={(event) => actions.setPeriodFromFilter(event.target.value)}
|
||||
className="app-input flex-1 rounded-lg px-3 py-2 text-sm"
|
||||
className="app-input min-w-0 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={state.periodToFilter}
|
||||
onChange={(event) => actions.setPeriodToFilter(event.target.value)}
|
||||
className="app-input flex-1 rounded-lg px-3 py-2 text-sm"
|
||||
className="app-input min-w-0 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.hasActiveFilters ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.clearFilters}
|
||||
className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium transition"
|
||||
>
|
||||
Очистить фильтры
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@ -53,6 +53,7 @@ type RequestListItemProps = {
|
||||
onToggleRow: (requestId: number) => void;
|
||||
request: Request;
|
||||
rowOpen: boolean;
|
||||
unreadNotificationCount?: number;
|
||||
};
|
||||
|
||||
function renderDecisionMaker(decisionMaker: User | undefined, currentUserId?: number | null) {
|
||||
@ -87,6 +88,7 @@ export function RequestListItem({
|
||||
onToggleRow,
|
||||
request,
|
||||
rowOpen,
|
||||
unreadNotificationCount = 0,
|
||||
}: RequestListItemProps) {
|
||||
const requestAuthor = request.employee || request.created_by;
|
||||
const authorName = displayUserName(requestAuthor);
|
||||
@ -148,6 +150,11 @@ export function RequestListItem({
|
||||
<div className="min-w-0 flex-[1_1_9rem]">
|
||||
{authorLink ? (
|
||||
<Link href={authorLink} className="group flex min-w-0 max-w-full items-center gap-2">
|
||||
{unreadNotificationCount > 0 ? (
|
||||
<span className="app-counter inline-flex h-5 min-w-5 shrink-0 items-center justify-center px-1 text-[10px] font-bold text-white">
|
||||
{unreadNotificationCount > 99 ? "99+" : unreadNotificationCount}
|
||||
</span>
|
||||
) : null}
|
||||
<RequestAvatar
|
||||
alt={authorName}
|
||||
fallback={authorFallback}
|
||||
@ -160,6 +167,11 @@ export function RequestListItem({
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex min-w-0 max-w-full items-center gap-2">
|
||||
{unreadNotificationCount > 0 ? (
|
||||
<span className="app-counter inline-flex h-5 min-w-5 shrink-0 items-center justify-center px-1 text-[10px] font-bold text-white">
|
||||
{unreadNotificationCount > 99 ? "99+" : unreadNotificationCount}
|
||||
</span>
|
||||
) : null}
|
||||
<RequestAvatar
|
||||
alt={authorName}
|
||||
fallback={authorFallback}
|
||||
|
||||
@ -29,10 +29,11 @@ type RequestListSectionProps = {
|
||||
requests: Request[];
|
||||
onSetCommentDraft: (requestId: number, value: string) => void;
|
||||
setAttachmentPreview: (preview: RequestAttachmentPreview | null) => void;
|
||||
setDetailsRequest: (request: Request | null) => void;
|
||||
openDetailsRequest: (request: Request) => void;
|
||||
setRequestMenuOpenId: (requestId: number | null) => void;
|
||||
toggleComments: (requestId: number) => void | Promise<void>;
|
||||
toggleRow: (requestId: number) => void;
|
||||
unreadNotificationCounts: Record<number, number>;
|
||||
};
|
||||
|
||||
export function RequestListSection({
|
||||
@ -60,10 +61,11 @@ export function RequestListSection({
|
||||
requests,
|
||||
onSetCommentDraft,
|
||||
setAttachmentPreview,
|
||||
setDetailsRequest,
|
||||
openDetailsRequest,
|
||||
setRequestMenuOpenId,
|
||||
toggleComments,
|
||||
toggleRow,
|
||||
unreadNotificationCounts,
|
||||
}: RequestListSectionProps) {
|
||||
return (
|
||||
<>
|
||||
@ -92,7 +94,7 @@ export function RequestListSection({
|
||||
onDeleteComment={handleDeleteComment}
|
||||
onEdit={openEdit}
|
||||
onLinkTask={onLinkTask}
|
||||
onOpenDetails={setDetailsRequest}
|
||||
onOpenDetails={openDetailsRequest}
|
||||
onPreviewAttachment={setAttachmentPreview}
|
||||
onReject={handleReject}
|
||||
onSetCommentDraft={onSetCommentDraft}
|
||||
@ -101,6 +103,7 @@ export function RequestListSection({
|
||||
onToggleRow={toggleRow}
|
||||
request={request}
|
||||
rowOpen={Boolean(expandedRows[request.id])}
|
||||
unreadNotificationCount={unreadNotificationCounts[request.id] || 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -19,13 +19,15 @@ type StatsPeriod = (typeof statsPeriods)[number]["value"];
|
||||
type RequestStatisticsPanelProps = {
|
||||
canView: boolean;
|
||||
employees: User[];
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export function RequestStatisticsPanel({
|
||||
canView,
|
||||
employees,
|
||||
embedded = false,
|
||||
}: RequestStatisticsPanelProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(embedded);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [employeeId, setEmployeeId] = useState<number | null>(null);
|
||||
const [period, setPeriod] = useState<StatsPeriod>("all");
|
||||
@ -34,11 +36,12 @@ export function RequestStatisticsPanel({
|
||||
const [stats, setStats] = useState<RequestEmployeeStatistics | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const panelOpen = embedded || open;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!canView
|
||||
|| !open
|
||||
|| !panelOpen
|
||||
|| !employeeId
|
||||
|| (period === "custom" && (!dateFrom || !dateTo))
|
||||
) {
|
||||
@ -84,7 +87,7 @@ export function RequestStatisticsPanel({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canView, dateFrom, dateTo, employeeId, open, period]);
|
||||
}, [canView, dateFrom, dateTo, employeeId, panelOpen, period]);
|
||||
|
||||
if (!canView) return null;
|
||||
|
||||
@ -117,28 +120,30 @@ export function RequestStatisticsPanel({
|
||||
] : [];
|
||||
|
||||
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>
|
||||
<div className={`${embedded ? "min-h-full" : "app-surface overflow-hidden rounded-2xl"}`}>
|
||||
{!embedded ? (
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
{open ? (
|
||||
<div className="border-t border-[var(--border-subtle)] px-4 py-4">
|
||||
<div className="app-surface-muted rounded-2xl p-4">
|
||||
{panelOpen ? (
|
||||
<div className={embedded ? "p-2" : "border-t border-[var(--border-subtle)] px-4 py-4"}>
|
||||
<div className={`app-surface-muted rounded-xl ${embedded ? "p-3" : "p-4"}`}>
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={16}
|
||||
@ -302,7 +307,7 @@ export function RequestStatisticsPanel({
|
||||
? "Период: текущий календарный месяц"
|
||||
: `Период: ${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">
|
||||
<div className={`grid gap-3 ${embedded ? "grid-cols-2" : "grid-cols-1 sm:grid-cols-2 xl:grid-cols-4"}`}>
|
||||
{statsItems.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
|
||||
111
frontend/src/components/requests/RequestWideListItem.tsx
Normal file
111
frontend/src/components/requests/RequestWideListItem.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import {
|
||||
defaultStatusMeta,
|
||||
formatRequestPeriod,
|
||||
requestTypeLabels,
|
||||
statusMeta,
|
||||
} from "@/hooks/useRequestsPage";
|
||||
import { displayUserName } from "@/lib/shared";
|
||||
import type { Request } from "@/types/api";
|
||||
import { Link2, MessageSquare, Paperclip } from "lucide-react";
|
||||
import { RequestAvatar } from "./RequestAvatar";
|
||||
|
||||
type RequestWideListItemProps = {
|
||||
active: boolean;
|
||||
onOpen: (request: Request) => void;
|
||||
request: Request;
|
||||
unreadNotificationCount?: number;
|
||||
};
|
||||
|
||||
export function RequestWideListItem({
|
||||
active,
|
||||
onOpen,
|
||||
request,
|
||||
unreadNotificationCount = 0,
|
||||
}: RequestWideListItemProps) {
|
||||
const author = request.employee || request.created_by;
|
||||
const authorName = displayUserName(author);
|
||||
const authorFallback = (author?.first_name?.[0] || author?.last_name?.[0] || "?").toUpperCase();
|
||||
const status = statusMeta[String(request.status || "").toLowerCase()] ?? defaultStatusMeta;
|
||||
const typeKey = String(request.type || request.request_type || "").toLowerCase();
|
||||
const typeLabel = requestTypeLabels[typeKey] || String(request.type || request.request_type || "Другое");
|
||||
const title = request.display_title || request.title || "Без заголовка";
|
||||
const summary = request.comment || request.description || "";
|
||||
const commentCount = request.comments_count ?? 0;
|
||||
const linkedTaskCount = request.linked_tasks?.length ?? 0;
|
||||
const hasAttachment = Boolean(request.attachment_url || request.attachment);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`${active ? "app-selected border-[var(--accent-primary)]" : "app-surface hover:border-[var(--border-strong)]"} relative overflow-hidden rounded-xl border transition`}
|
||||
aria-current={active ? "true" : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(request)}
|
||||
className="block w-full p-3 text-left"
|
||||
>
|
||||
<span className="flex min-w-0 items-start gap-2.5">
|
||||
<RequestAvatar
|
||||
alt={authorName}
|
||||
fallback={authorFallback}
|
||||
size="md"
|
||||
src={author?.avatar}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-start justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{unreadNotificationCount > 0 ? (
|
||||
<span
|
||||
className="app-counter inline-flex h-5 min-w-5 shrink-0 items-center justify-center px-1 text-[10px] font-bold text-white"
|
||||
title={`Новых уведомлений: ${unreadNotificationCount}`}
|
||||
aria-label={`Новых уведомлений: ${unreadNotificationCount}`}
|
||||
>
|
||||
{unreadNotificationCount > 99 ? "99+" : unreadNotificationCount}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="min-w-0 truncate text-sm font-semibold text-[var(--foreground)]">
|
||||
{authorName}
|
||||
</span>
|
||||
</span>
|
||||
<span className={`app-status-pill !shrink-0 !whitespace-nowrap ${status.className}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</span>
|
||||
<span className="app-text-wrap mt-1 block line-clamp-2 text-xs font-medium text-[var(--foreground)]">
|
||||
<span className="app-text-muted">{typeLabel}:</span>{" "}{title}
|
||||
</span>
|
||||
<span className="app-text-muted mt-1 block truncate text-[11px]">
|
||||
{typeKey === "short_day_off" ? "Время" : "Период"}: {formatRequestPeriod(request)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{summary ? (
|
||||
<span className="app-text-muted mt-2 block line-clamp-2 text-xs leading-relaxed">
|
||||
{summary}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{(commentCount > 0 || linkedTaskCount > 0 || hasAttachment) ? (
|
||||
<span className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{commentCount > 0 ? (
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px]">
|
||||
<MessageSquare size={10} /> {commentCount}
|
||||
</span>
|
||||
) : null}
|
||||
{linkedTaskCount > 0 ? (
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px]">
|
||||
<Link2 size={10} /> {linkedTaskCount}
|
||||
</span>
|
||||
) : null}
|
||||
{hasAttachment ? (
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px]">
|
||||
<Paperclip size={10} /> Файл
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@ -39,6 +39,7 @@ interface NotificationsContextType {
|
||||
notifications: NotificationItem[];
|
||||
unreadCount: number;
|
||||
unreadCategoryCounts: Record<string, number>;
|
||||
unreadEmployeeRequestCounts: Record<number, number>;
|
||||
unreadProcurementRequestCounts: Record<number, number>;
|
||||
unreadProcurementItemCommentCounts: Record<number, number>;
|
||||
unreadTaskBoardCounts: Record<number, number>;
|
||||
@ -66,6 +67,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [unreadCategoryCounts, setUnreadCategoryCounts] = useState<Record<string, number>>({});
|
||||
const [unreadEmployeeRequestCounts, setUnreadEmployeeRequestCounts] = useState<Record<number, number>>({});
|
||||
const [unreadProcurementRequestCounts, setUnreadProcurementRequestCounts] = useState<Record<number, number>>({});
|
||||
const [unreadProcurementItemCommentCounts, setUnreadProcurementItemCommentCounts] = useState<Record<number, number>>({});
|
||||
const [unreadTaskBoardCounts, setUnreadTaskBoardCounts] = useState<Record<number, number>>({});
|
||||
@ -82,6 +84,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
const applyUnreadSummary = useCallback((summary: {
|
||||
total?: number;
|
||||
verbs?: Array<{ verb?: string; unread?: number }>;
|
||||
employee_requests?: Array<{ request_id?: number; unread?: number }>;
|
||||
procurement_requests?: Array<{ request_id?: number; unread?: number }>;
|
||||
procurement_item_comments?: Array<{ item_id?: number; unread?: number }>;
|
||||
task_boards?: Array<{ board_id?: number; unread?: number }>;
|
||||
@ -90,8 +93,12 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
regulation_personal_unread?: number;
|
||||
}) => {
|
||||
const nextCategoryCounts: Record<string, number> = {};
|
||||
const nextEmployeeRequestCounts: Record<number, number> = {};
|
||||
const nextProcurementRequestCounts: Record<number, number> = {};
|
||||
const verbs = Array.isArray(summary.verbs) ? summary.verbs : [];
|
||||
const employeeRequests = Array.isArray(summary.employee_requests)
|
||||
? summary.employee_requests
|
||||
: [];
|
||||
const procurementRequests = Array.isArray(summary.procurement_requests)
|
||||
? summary.procurement_requests
|
||||
: [];
|
||||
@ -113,6 +120,14 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
nextCategoryCounts[category] = (nextCategoryCounts[category] || 0) + item.unread;
|
||||
});
|
||||
|
||||
employeeRequests.forEach((item) => {
|
||||
const requestId = Number(item.request_id);
|
||||
if (!Number.isFinite(requestId) || requestId <= 0 || typeof item.unread !== 'number' || item.unread <= 0) {
|
||||
return;
|
||||
}
|
||||
nextEmployeeRequestCounts[requestId] = item.unread;
|
||||
});
|
||||
|
||||
procurementRequests.forEach((item) => {
|
||||
const requestId = Number(item.request_id);
|
||||
if (!Number.isFinite(requestId) || requestId <= 0 || typeof item.unread !== 'number' || item.unread <= 0) {
|
||||
@ -149,6 +164,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
});
|
||||
|
||||
setUnreadCategoryCounts(nextCategoryCounts);
|
||||
setUnreadEmployeeRequestCounts(nextEmployeeRequestCounts);
|
||||
setUnreadProcurementRequestCounts(nextProcurementRequestCounts);
|
||||
setUnreadProcurementItemCommentCounts(nextProcurementItemCommentCounts);
|
||||
setUnreadTaskBoardCounts(nextTaskBoardCounts);
|
||||
@ -235,6 +251,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
setUnreadCategoryCounts({});
|
||||
setUnreadEmployeeRequestCounts({});
|
||||
setUnreadProcurementRequestCounts({});
|
||||
setUnreadProcurementItemCommentCounts({});
|
||||
setUnreadTaskBoardCounts({});
|
||||
@ -285,6 +302,15 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
...prev,
|
||||
[category]: (prev[category] || 0) + 1,
|
||||
}));
|
||||
if (category === 'Заявления') {
|
||||
const requestId = Number(incomingNotification.data?.request_id);
|
||||
if (Number.isFinite(requestId) && requestId > 0) {
|
||||
setUnreadEmployeeRequestCounts(prev => ({
|
||||
...prev,
|
||||
[requestId]: (prev[requestId] || 0) + 1,
|
||||
}));
|
||||
}
|
||||
}
|
||||
if (category === 'Закупки') {
|
||||
const requestId = Number(incomingNotification.data?.request_id);
|
||||
if (Number.isFinite(requestId) && requestId > 0) {
|
||||
@ -433,6 +459,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true, unread: false })));
|
||||
setUnreadCount(0);
|
||||
setUnreadCategoryCounts({});
|
||||
setUnreadEmployeeRequestCounts({});
|
||||
setUnreadProcurementRequestCounts({});
|
||||
setUnreadProcurementItemCommentCounts({});
|
||||
setUnreadTaskBoardCounts({});
|
||||
@ -459,6 +486,9 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
delete next[category];
|
||||
return next;
|
||||
});
|
||||
if (category === 'Заявления') {
|
||||
setUnreadEmployeeRequestCounts({});
|
||||
}
|
||||
if (category === 'Закупки') {
|
||||
setUnreadProcurementRequestCounts({});
|
||||
setUnreadProcurementItemCommentCounts({});
|
||||
@ -509,6 +539,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
notifications,
|
||||
unreadCount,
|
||||
unreadCategoryCounts,
|
||||
unreadEmployeeRequestCounts,
|
||||
unreadProcurementRequestCounts,
|
||||
unreadProcurementItemCommentCounts,
|
||||
unreadTaskBoardCounts,
|
||||
|
||||
59
frontend/src/hooks/useRequestsDesktopWideMode.ts
Normal file
59
frontend/src/hooks/useRequestsDesktopWideMode.ts
Normal file
@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
const STORAGE_PREFIX = "requests.desktop-wide-mode";
|
||||
const CHANGE_EVENT = "requests-desktop-wide-mode-change";
|
||||
|
||||
function storageKey(userId: number): string {
|
||||
return `${STORAGE_PREFIX}.${userId}`;
|
||||
}
|
||||
|
||||
export function useRequestsDesktopWideMode(userId?: number | null) {
|
||||
const subscribe = useCallback((notify: () => void) => {
|
||||
if (!userId) return () => undefined;
|
||||
const key = storageKey(userId);
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key === key) notify();
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
window.addEventListener(CHANGE_EVENT, notify);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener(CHANGE_EVENT, notify);
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
const getSnapshot = useCallback(() => (
|
||||
userId ? window.localStorage.getItem(storageKey(userId)) === "1" : false
|
||||
), [userId]);
|
||||
|
||||
const desktopWideMode = useSyncExternalStore(subscribe, getSnapshot, () => false);
|
||||
|
||||
const subscribeDesktopViewport = useCallback((notify: () => void) => {
|
||||
const query = window.matchMedia("(min-width: 1024px)");
|
||||
query.addEventListener("change", notify);
|
||||
return () => query.removeEventListener("change", notify);
|
||||
}, []);
|
||||
const getDesktopViewportSnapshot = useCallback(
|
||||
() => window.matchMedia("(min-width: 1024px)").matches,
|
||||
[],
|
||||
);
|
||||
const desktopViewport = useSyncExternalStore(
|
||||
subscribeDesktopViewport,
|
||||
getDesktopViewportSnapshot,
|
||||
() => false,
|
||||
);
|
||||
|
||||
const changeDesktopWideMode = useCallback((enabled: boolean) => {
|
||||
if (!userId) return;
|
||||
window.localStorage.setItem(storageKey(userId), enabled ? "1" : "0");
|
||||
window.dispatchEvent(new Event(CHANGE_EVENT));
|
||||
}, [userId]);
|
||||
|
||||
return [
|
||||
desktopWideMode,
|
||||
changeDesktopWideMode,
|
||||
desktopWideMode && desktopViewport,
|
||||
] as const;
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useNotifications } from "@/contexts/NotificationsContext";
|
||||
import type { Request, RequestComment, User, Department } from "@/types/api";
|
||||
import { extractNextPage, loadAllPages, displayUserName, formatDate } from "@/lib/shared";
|
||||
|
||||
@ -68,6 +69,22 @@ export const orderingOptions = [
|
||||
{ value: "-date_from", label: "По периоду ↓" },
|
||||
];
|
||||
|
||||
export type RequestQuickScope = "" | "mine" | "addressed" | "decision";
|
||||
|
||||
export type RequestScopeCounts = {
|
||||
all: number;
|
||||
mine: number;
|
||||
addressed: number;
|
||||
decision: number;
|
||||
};
|
||||
|
||||
const emptyScopeCounts: RequestScopeCounts = {
|
||||
all: 0,
|
||||
mine: 0,
|
||||
addressed: 0,
|
||||
decision: 0,
|
||||
};
|
||||
|
||||
export function getRequestDateMode(type: string): "range" | "single" | "timed" | "optional" {
|
||||
switch (String(type || "").toLowerCase()) {
|
||||
case "short_day_off":
|
||||
@ -137,6 +154,7 @@ function getRequestSubmitError(form: RequestFormState): string | null {
|
||||
/* ── hook ── */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function useRequestsPage(_userId: number | null | undefined) {
|
||||
const { refreshUnreadSummary, unreadEmployeeRequestCounts } = useNotifications();
|
||||
/* data */
|
||||
const [requests, setRequests] = useState<Request[]>([]);
|
||||
const [employees, setEmployees] = useState<User[]>([]);
|
||||
@ -155,7 +173,8 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
|
||||
/* filters */
|
||||
const [search, setSearch] = useState("");
|
||||
const [view, setView] = useState<"" | "mine" | "addressed">("");
|
||||
const [view, setView] = useState<RequestQuickScope>("");
|
||||
const [scopeCounts, setScopeCounts] = useState<RequestScopeCounts>(emptyScopeCounts);
|
||||
const [typeFilter, setTypeFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [employeeFilter, setEmployeeFilter] = useState("");
|
||||
@ -189,6 +208,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
const params: Record<string, string | number> = { page, limit: 20 };
|
||||
if (view === "mine") params.view = "mine";
|
||||
if (view === "addressed") params.addressed_to_me = "true";
|
||||
if (view === "decision") params.decision_required = "true";
|
||||
if (typeFilter) params.type = typeFilter;
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (employeeFilter) params.employee_id = employeeFilter;
|
||||
@ -206,6 +226,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
const nextRequests = response.results || [];
|
||||
setRequests(nextRequests);
|
||||
setNextPage(extractNextPage(response.next));
|
||||
setScopeCounts(response.scope_counts || emptyScopeCounts);
|
||||
setDetailsRequest((prev) => {
|
||||
if (!prev) return prev;
|
||||
const updated = nextRequests.find((request: Request) => request.id === prev.id);
|
||||
@ -225,6 +246,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
if (cancelled) return;
|
||||
setRequests(response.results || []);
|
||||
setNextPage(extractNextPage(response.next));
|
||||
setScopeCounts(response.scope_counts || emptyScopeCounts);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error("Ошибка загрузки заявлений:", err);
|
||||
@ -290,7 +312,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
() => filteredRequests.filter((request) => String(request.status || "").toLowerCase() === "pending" && request.can_decide),
|
||||
[filteredRequests],
|
||||
);
|
||||
const pendingDecisionCount = pendingDecisionRequests.length;
|
||||
const pendingDecisionCount = scopeCounts.decision;
|
||||
const activeFiltersCount = useMemo(
|
||||
() => [
|
||||
view,
|
||||
@ -409,6 +431,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
const response = await apiClient.getRequests(buildRequestParams(1));
|
||||
setRequests(response.results || []);
|
||||
setNextPage(extractNextPage(response.next));
|
||||
setScopeCounts(response.scope_counts || emptyScopeCounts);
|
||||
} catch (e: unknown) {
|
||||
const raw = String((e as Error)?.message || "Не удалось сохранить заявление");
|
||||
let readable = raw;
|
||||
@ -449,19 +472,19 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
}, [nextPage, loadingMore, buildRequestParams]);
|
||||
|
||||
const handleApprove = async (id: number) => {
|
||||
try { setBusyKey(`approve-${id}`); setActionError(null); const updated = await apiClient.approveRequest(id); applyUpdatedRequest(updated); }
|
||||
try { setBusyKey(`approve-${id}`); setActionError(null); const updated = await apiClient.approveRequest(id); applyUpdatedRequest(updated); await refreshRequests(); }
|
||||
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось одобрить")); }
|
||||
finally { setBusyKey(null); }
|
||||
};
|
||||
|
||||
const handleReject = async (id: number) => {
|
||||
try { setBusyKey(`reject-${id}`); setActionError(null); const updated = await apiClient.rejectRequest(id); applyUpdatedRequest(updated); }
|
||||
try { setBusyKey(`reject-${id}`); setActionError(null); const updated = await apiClient.rejectRequest(id); applyUpdatedRequest(updated); await refreshRequests(); }
|
||||
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось отклонить")); }
|
||||
finally { setBusyKey(null); }
|
||||
};
|
||||
|
||||
const handleCancel = async (id: number) => {
|
||||
try { setBusyKey(`cancel-${id}`); setActionError(null); const updated = await apiClient.cancelRequest(id); applyUpdatedRequest(updated); }
|
||||
try { setBusyKey(`cancel-${id}`); setActionError(null); const updated = await apiClient.cancelRequest(id); applyUpdatedRequest(updated); await refreshRequests(); }
|
||||
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось отменить")); }
|
||||
finally { setBusyKey(null); }
|
||||
};
|
||||
@ -476,6 +499,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
await apiClient.deleteRequest(id);
|
||||
setRequests((p) => p.filter((r) => r.id !== id));
|
||||
setDetailsRequest((prev) => (prev?.id === id ? null : prev));
|
||||
await refreshRequests();
|
||||
}
|
||||
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось удалить")); }
|
||||
finally { setBusyKey(null); }
|
||||
@ -534,6 +558,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
const isFinal = (status?: string) => ["approved", "rejected", "cancelled"].includes(String(status || "").toLowerCase());
|
||||
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const readRequestIdRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
@ -547,14 +572,35 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = detailsRequest?.id;
|
||||
if (!requestId) return;
|
||||
if (!requestId) {
|
||||
readRequestIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (String(detailsRequest.status || "").toLowerCase() === "draft") return;
|
||||
void ensureCommentsLoaded(requestId);
|
||||
}, [detailsRequest, ensureCommentsLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = detailsRequest?.id;
|
||||
if (!requestId) {
|
||||
readRequestIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (readRequestIdRef.current === requestId) return;
|
||||
|
||||
readRequestIdRef.current = requestId;
|
||||
void apiClient.markRequestNotificationsRead(requestId)
|
||||
.then(() => refreshUnreadSummary())
|
||||
.catch((readError) => {
|
||||
readRequestIdRef.current = null;
|
||||
console.error("Не удалось отметить уведомления заявления прочитанными:", readError);
|
||||
});
|
||||
}, [detailsRequest?.id, refreshUnreadSummary]);
|
||||
|
||||
return {
|
||||
/* data */
|
||||
requests: filteredRequests,
|
||||
unreadEmployeeRequestCounts,
|
||||
pendingDecisionRequests,
|
||||
employees,
|
||||
recipientEmployees,
|
||||
@ -583,6 +629,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
/* filters */
|
||||
search, setSearch,
|
||||
view, setView,
|
||||
scopeCounts,
|
||||
typeFilter, setTypeFilter,
|
||||
statusFilter, setStatusFilter,
|
||||
employeeFilter, setEmployeeFilter,
|
||||
|
||||
@ -37,6 +37,13 @@ export function useRequestsPageScreen({
|
||||
clearRequestParam();
|
||||
};
|
||||
|
||||
const openDetailsRequest = (request: Request) => {
|
||||
setDetailsRequest(request);
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.set("request", String(request.id));
|
||||
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkedRequestId) {
|
||||
openedLinkedRequestIdRef.current = null;
|
||||
@ -114,6 +121,7 @@ export function useRequestsPageScreen({
|
||||
|
||||
return {
|
||||
closeDetailsRequest,
|
||||
openDetailsRequest,
|
||||
requestMenuOpenId,
|
||||
requestMenuRef,
|
||||
setRequestMenuOpenId,
|
||||
|
||||
@ -12,6 +12,10 @@ export type UnreadNotificationsSummary = {
|
||||
verb: string;
|
||||
unread: number;
|
||||
}>;
|
||||
employee_requests?: Array<{
|
||||
request_id: number;
|
||||
unread: number;
|
||||
}>;
|
||||
procurement_requests?: Array<{
|
||||
request_id: number;
|
||||
unread: number;
|
||||
|
||||
@ -19,13 +19,15 @@ export function createRequestsApi(request: RequestFn) {
|
||||
getRequests: (params?: Record<string, any>) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params) {
|
||||
const keys = ['status','type','search','page','limit','view','addressed_to_me','employee_id','created_from','created_to','date_from','date_to'];
|
||||
const keys = ['status','type','search','page','limit','view','addressed_to_me','decision_required','employee_id','created_from','created_to','date_from','date_to'];
|
||||
keys.forEach(k => { if (params[k]) qp.append(k, String(params[k])); });
|
||||
}
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/requests/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/`),
|
||||
markRequestNotificationsRead: (requestId: number): Promise<{ status: string; count: number; notification_ids: number[] }> =>
|
||||
request(`/api/v1/requests/${requestId}/notifications/read/`, { method: 'POST' }),
|
||||
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> =>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user