improve requests workspace and notifications

This commit is contained in:
lizerk 2026-08-07 14:36:59 +03:00
parent c0092437e1
commit 2fec931bda
21 changed files with 1198 additions and 115 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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" />

View File

@ -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 && (

View File

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

View 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>
);
}

View File

@ -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}
</>

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

View 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;
}

View File

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

View File

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

View File

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

View File

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