diff --git a/docs/guides/frontend/01-OVERVIEW.md b/docs/guides/frontend/01-OVERVIEW.md index eac4ac8f..ab291c20 100644 --- a/docs/guides/frontend/01-OVERVIEW.md +++ b/docs/guides/frontend/01-OVERVIEW.md @@ -67,6 +67,8 @@ Guide не покрывает: - hook не кодирует дизайн-систему - hook может возвращать данные для UI, но не должен становиться хранилищем визуальных классов +- page-specific orchestration вроде router-sync, deep-link открытия detail modal и menu dismiss logic допустимо выносить в feature hook, если page-level файл начинает разрастаться +- derived actions уровня feature, например `clearFilters` или готовая выборка `pendingDecisionRequests`, лучше держать рядом с feature hook, а не собирать заново в page-level JSX ### `frontend/src/lib` diff --git a/docs/guides/frontend/05-MIGRATION.md b/docs/guides/frontend/05-MIGRATION.md index 2c2628ee..d55f2822 100644 --- a/docs/guides/frontend/05-MIGRATION.md +++ b/docs/guides/frontend/05-MIGRATION.md @@ -47,6 +47,8 @@ Legacy не означает “удалить немедленно”. Это - идеи “форма как рабочее пространство” - разрезания page-level orchestration и feature-level list/detail/controls/preview блоков +- вынесения screen-specific router/menu/deep-link orchestration в отдельный hook +- feature containers уровня list-section / swipe-mode panel, которые собирают несколько leaf-компонентов внутри одного сценария ### `procurement` diff --git a/docs/guides/frontend/06-COMPONENT-INVENTORY.md b/docs/guides/frontend/06-COMPONENT-INVENTORY.md index eb0e42d1..1dbcffad 100644 --- a/docs/guides/frontend/06-COMPONENT-INVENTORY.md +++ b/docs/guides/frontend/06-COMPONENT-INVENTORY.md @@ -166,10 +166,13 @@ - `requests/RequestDetailModal.tsx` - `requests/RequestListControls.tsx` - `requests/RequestListItem.tsx` +- `requests/RequestListSection.tsx` +- `requests/RequestSwipeModePanel.tsx` - `requests/RequestUserBadge.tsx` - `requests/SwipeApprovalMode.tsx` - `app/requests/page.tsx` - `hooks/useRequestsPage.ts` +- `hooks/useRequestsPageScreen.ts` Использовать как референс для: @@ -177,11 +180,14 @@ - detail modal с participant-heavy content - page-level toolbar/filter block, который вынесен из страницы в feature component - dense list row с actions/comments/expandable secondary content +- feature container для list shell, который собирает rows + empty state + load more +- отдельный mode-panel для alternate flow вроде swipe-review - attachment preview modal как отдельный feature block, а не хвост page-level JSX - participant badge pattern для request-like доменов - participant-only UI decisions - conditional action rendering по данным API - разделения page-level orchestration и feature-level compose workspace +- screen-specific router/menu/deep-link orchestration без засорения page-level JSX ### Users / profile-like screens diff --git a/docs/guides/frontend/07-DEVELOPER-WORKFLOW.md b/docs/guides/frontend/07-DEVELOPER-WORKFLOW.md index 0a44bda3..410c790f 100644 --- a/docs/guides/frontend/07-DEVELOPER-WORKFLOW.md +++ b/docs/guides/frontend/07-DEVELOPER-WORKFLOW.md @@ -76,6 +76,8 @@ - если у страницы появились тяжёлые expandable rows, detail modal или participant-heavy blocks, это уже кандидат на feature components, а не на дальнейшее разрастание page-level JSX - то же касается toolbar/filter block и attachment preview, если они уже стали самостоятельными зонами сценария +- router-sync, deep-link opening и dismissable menu state тоже можно выносить в feature hook, если это screen orchestration, а не данные домена +- если внутри одной зоны уже несколько leaf-компонентов, стоит ввести feature container вроде `*Section` или `*Panel`, а не возвращать всё обратно в page-level JSX ## 5. Как делать dropdown-поле diff --git a/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md b/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md index 76e62540..202161a6 100644 --- a/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md +++ b/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md @@ -13,7 +13,7 @@ | Зона | Статус | Что уже хорошо | Основные проблемы | Рекомендуемый следующий шаг | | --- | --- | --- | --- | --- | -| `requests` | `mixed` | compose-настроение, participant-only логика, UI вокруг `can_decide`, compose/list/detail/controls/preview уже разведены по feature components | visual debt остаётся в list shell и отдельных локальных визуальных решениях; зона ещё не стала полным target-state | использовать как референс для разделения orchestration/UI и participant-heavy request flows, затем отдельно чистить list shell | +| `requests` | `mixed` | compose-настроение, participant-only логика, UI вокруг `can_decide`, compose/list/detail/controls/preview разведены по feature components, screen orchestration вынесена в hook, list/swipe flow собраны в feature containers | visual debt остаётся в отдельных локальных визуальных решениях; зона ещё не стала полным target-state | использовать как референс для разделения orchestration/UI и participant-heavy request flows, затем отдельно чистить локальную визуальную неоднородность | | `settings` | `mixed` | сильная surface hierarchy, хорошие primary sections | встречаются custom radii и локальная геометрическая неоднородность | рефакторить карточки и кастомные радиусы без смены общего layout | | `documents` | `needs refactor` | богатый набор сценариев, detail/upload/comment patterns уже выделены в компоненты | смешение `rounded-md`, `rounded-lg`, `rounded-xl`, разные уровни visual density | сделать отдельный pass по карточкам и metadata/upload flows | | `equipment` | `needs refactor` | понятные формы и вторичные блоки | много локальных card/form решений, геометрия плавает | после `documents` пройтись по secondary cards и action forms | @@ -33,7 +33,7 @@ 4. `procurement` 5. отдельный audit по `calendar` -`requests` лучше использовать как референс для compose-настроения и выделения feature-level workspace/list-detail/controls блоков уже сейчас, а не как первую цель глобального переписывания. +`requests` лучше использовать как референс для compose-настроения, выделения feature-level workspace/list-detail/controls блоков, screen-specific orchestration hooks и feature containers уже сейчас, а не как первую цель глобального переписывания. ## Как использовать эту карту diff --git a/frontend/src/app/requests/page.tsx b/frontend/src/app/requests/page.tsx index c1c2af91..4b4b2a5c 100644 --- a/frontend/src/app/requests/page.tsx +++ b/frontend/src/app/requests/page.tsx @@ -1,21 +1,16 @@ "use client"; -/* eslint-disable react-hooks/refs -- All h.* values come from useState, not useRef. React compiler false positive. */ import { AppShell } from "../../components/AppShell"; import { RequestAttachmentPreviewModal } from "@/components/requests/RequestAttachmentPreviewModal"; import { RequestComposeModal } from "@/components/requests/RequestComposeModal"; import { RequestDetailModal } from "@/components/requests/RequestDetailModal"; import { RequestListControls } from "@/components/requests/RequestListControls"; -import { RequestListItem } from "@/components/requests/RequestListItem"; -import { apiClient } from "@/lib/api"; +import { RequestListSection } from "@/components/requests/RequestListSection"; +import { RequestSwipeModePanel } from "@/components/requests/RequestSwipeModePanel"; import { useUser } from "@/contexts/UserContext"; -import { Suspense, useEffect, useMemo, useRef, useState } from "react"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { FileSignature, Zap } from "lucide-react"; -import dynamic from "next/dynamic"; +import { Suspense } from "react"; import { useRequestsPage } from "@/hooks/useRequestsPage"; - -const SwipeApprovalMode = dynamic(() => import("@/components/requests/SwipeApprovalMode"), { ssr: false }); +import { useRequestsPageScreen } from "@/hooks/useRequestsPageScreen"; export default function RequestsPage() { return ( @@ -38,110 +33,12 @@ function RequestsPageFallback() { function RequestsPageContent() { const { user } = useUser(); - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); const h = useRequestsPage(user?.id); - const requestMenuRef = useRef(null); - const openedLinkedRequestIdRef = useRef(null); - const loadingLinkedRequestIdRef = useRef(null); - const [requestMenuOpenId, setRequestMenuOpenId] = useState(null); - const linkedRequestId = Number(searchParams.get("request") || ""); - - const pendingDecisionRequests = useMemo( - () => h.requests.filter((request) => String(request.status || "").toLowerCase() === "pending" && request.can_decide), - [h.requests], - ); - const detailsRequestId = h.detailsRequest?.id; - const requestList = h.requests; - const setDetailsRequest = h.setDetailsRequest; - - const clearRequestParam = () => { - if (!searchParams.get("request")) return; - const nextParams = new URLSearchParams(searchParams.toString()); - nextParams.delete("request"); - router.replace(nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname, { scroll: false }); - }; - - const closeDetailsRequest = () => { - h.setDetailsRequest(null); - clearRequestParam(); - }; - - useEffect(() => { - if (!linkedRequestId) { - openedLinkedRequestIdRef.current = null; - loadingLinkedRequestIdRef.current = null; - return; - } - - if (detailsRequestId === linkedRequestId) { - openedLinkedRequestIdRef.current = linkedRequestId; - loadingLinkedRequestIdRef.current = null; - return; - } - - if (openedLinkedRequestIdRef.current === linkedRequestId) { - return; - } - - const existing = requestList.find((item) => item.id === linkedRequestId); - if (existing) { - openedLinkedRequestIdRef.current = linkedRequestId; - loadingLinkedRequestIdRef.current = null; - setDetailsRequest(existing); - return; - } - - if (loadingLinkedRequestIdRef.current === linkedRequestId) { - return; - } - - loadingLinkedRequestIdRef.current = linkedRequestId; - - let cancelled = false; - - apiClient.getRequest(linkedRequestId) - .then((request) => { - if (!cancelled && openedLinkedRequestIdRef.current !== linkedRequestId) { - openedLinkedRequestIdRef.current = linkedRequestId; - loadingLinkedRequestIdRef.current = null; - setDetailsRequest(request); - } - }) - .catch((error) => { - loadingLinkedRequestIdRef.current = null; - console.error("Ошибка deep-link заявления:", error); - }); - - return () => { - cancelled = true; - }; - }, [detailsRequestId, linkedRequestId, requestList, setDetailsRequest]); - - useEffect(() => { - if (requestMenuOpenId === null) return; - - const handlePointerDown = (event: MouseEvent) => { - if (requestMenuRef.current && !requestMenuRef.current.contains(event.target as Node)) { - setRequestMenuOpenId(null); - } - }; - - const handleEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setRequestMenuOpenId(null); - } - }; - - document.addEventListener("mousedown", handlePointerDown); - document.addEventListener("keydown", handleEscape); - - return () => { - document.removeEventListener("mousedown", handlePointerDown); - document.removeEventListener("keydown", handleEscape); - }; - }, [requestMenuOpenId]); + const screen = useRequestsPageScreen({ + detailsRequestId: h.detailsRequest?.id, + requests: h.requests, + setDetailsRequest: h.setDetailsRequest, + }); return ( @@ -154,15 +51,14 @@ function RequestsPageContent() {

{h.error}

) : (
- {h.swipeMode && pendingDecisionRequests.length > 0 ? ( -
-
-

Быстрый разбор

- -
- {h.actionError &&

{h.actionError}

} - h.setSwipeMode(false)} /> -
+ {h.swipeMode && h.pendingDecisionRequests.length > 0 ? ( + h.setSwipeMode(false)} + onReject={h.handleReject} + requests={h.pendingDecisionRequests} + /> ) : ( <> { - h.setView(""); - h.setTypeFilter(""); - h.setStatusFilter(""); - h.setEmployeeFilter(""); - h.setCreatedFromFilter(""); - h.setCreatedToFilter(""); - h.setPeriodFromFilter(""); - h.setPeriodToFilter(""); - }} + onClearFilters={h.clearFilters} onOpenCreate={h.openCreate} onSetCreatedFromFilter={h.setCreatedFromFilter} onSetCreatedToFilter={h.setCreatedToFilter} @@ -197,7 +84,7 @@ function RequestsPageContent() { onStartSwipeMode={() => h.setSwipeMode(true)} onToggleFilters={() => h.setFiltersOpen((value) => !value)} ordering={h.ordering} - pendingDecisionCount={pendingDecisionRequests.length} + pendingDecisionCount={h.pendingDecisionRequests.length} periodFromFilter={h.periodFromFilter} periodToFilter={h.periodToFilter} search={h.search} @@ -205,42 +92,35 @@ function RequestsPageContent() { typeFilter={h.typeFilter} view={h.view} /> - -
- {h.requests.length === 0 ? ( -

Заявления не найдены

- ) : h.requests.map((item) => ( - h.setCommentDrafts((prev) => ({ ...prev, [requestId]: value }))} - onToggleComments={h.toggleComments} - onToggleMenu={setRequestMenuOpenId} - onToggleRow={h.toggleRow} - request={item} - rowOpen={Boolean(h.expandedRows[item.id])} - /> - ))} -
- - {h.nextPage &&
{h.loadingMore &&
Загрузка...
}
} + )}
@@ -249,7 +129,7 @@ function RequestsPageContent() { diff --git a/frontend/src/components/requests/RequestAttachmentPreviewModal.tsx b/frontend/src/components/requests/RequestAttachmentPreviewModal.tsx index 415358b5..373fd1a5 100644 --- a/frontend/src/components/requests/RequestAttachmentPreviewModal.tsx +++ b/frontend/src/components/requests/RequestAttachmentPreviewModal.tsx @@ -1,12 +1,10 @@ import { Modal } from "@/components/ui"; +import type { RequestAttachmentPreview } from "@/hooks/useRequestsPage"; import { FileSignature } from "lucide-react"; type RequestAttachmentPreviewModalProps = { onClose: () => void; - preview: { - name: string; - url: string; - } | null; + preview: RequestAttachmentPreview | null; }; export function RequestAttachmentPreviewModal({ diff --git a/frontend/src/components/requests/RequestComposeModal.tsx b/frontend/src/components/requests/RequestComposeModal.tsx index 34f8e6f1..0cd9eefd 100644 --- a/frontend/src/components/requests/RequestComposeModal.tsx +++ b/frontend/src/components/requests/RequestComposeModal.tsx @@ -2,7 +2,12 @@ import { Modal } from "@/components/ui"; import { SearchableSelectMulti, SearchableSelectSingle } from "@/components/shared/SearchableSelect"; -import { getRequestDateMode, requestTypeLabels, type RequestFormState } from "@/hooks/useRequestsPage"; +import { + getRequestDateMode, + requestTypeLabels, + type RequestAttachmentPreview, + type RequestFormState, +} from "@/hooks/useRequestsPage"; import { displayUserName } from "@/lib/shared"; import type { Request, User } from "@/types/api"; import { Paperclip, X } from "lucide-react"; @@ -15,11 +20,6 @@ import { type SetStateAction, } from "react"; -type AttachmentPreview = { - url: string; - name: string; -}; - type RequestComposeModalProps = { actionError: string | null; busyKey: string | null; @@ -29,7 +29,7 @@ type RequestComposeModalProps = { form: RequestFormState; mode: "create" | "edit"; onClose: () => void; - onPreviewAttachment: (preview: AttachmentPreview) => void; + onPreviewAttachment: (preview: RequestAttachmentPreview) => void; onSubmit: (mode: "create" | "edit", saveAs: "draft" | "submit") => void | Promise; setForm: Dispatch>; }; diff --git a/frontend/src/components/requests/RequestDetailModal.tsx b/frontend/src/components/requests/RequestDetailModal.tsx index 98087c56..22bbccf0 100644 --- a/frontend/src/components/requests/RequestDetailModal.tsx +++ b/frontend/src/components/requests/RequestDetailModal.tsx @@ -1,20 +1,20 @@ import { Modal } from "@/components/ui"; -import { defaultStatusMeta, requestTypeLabels, statusMeta } from "@/hooks/useRequestsPage"; +import { + defaultStatusMeta, + requestTypeLabels, + statusMeta, + type RequestAttachmentPreview, +} from "@/hooks/useRequestsPage"; import { formatDate, formatDateTime } from "@/lib/shared"; import type { Request } from "@/types/api"; import { Paperclip } from "lucide-react"; import { RequestUserBadge } from "./RequestUserBadge"; -type AttachmentPreview = { - url: string; - name: string; -}; - type RequestDetailModalProps = { currentUserId?: number | null; departmentNameMap: Map; onClose: () => void; - onPreviewAttachment: (preview: AttachmentPreview) => void; + onPreviewAttachment: (preview: RequestAttachmentPreview) => void; request: Request | null; }; diff --git a/frontend/src/components/requests/RequestListItem.tsx b/frontend/src/components/requests/RequestListItem.tsx index 68c1f981..6334fff7 100644 --- a/frontend/src/components/requests/RequestListItem.tsx +++ b/frontend/src/components/requests/RequestListItem.tsx @@ -1,16 +1,16 @@ import Link from "next/link"; -import { defaultStatusMeta, requestTypeLabels, statusMeta } from "@/hooks/useRequestsPage"; +import { + defaultStatusMeta, + requestTypeLabels, + statusMeta, + type RequestAttachmentPreview, +} from "@/hooks/useRequestsPage"; import { displayUserName, formatDate, userProfileLink } from "@/lib/shared"; import type { Request, RequestComment } from "@/types/api"; import { Ban, ChevronDown, ChevronRight, MessageSquare, Paperclip, Pencil, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"; import type { Ref } from "react"; import { RequestUserBadge } from "./RequestUserBadge"; -type AttachmentPreview = { - url: string; - name: string; -}; - type RequestListItemProps = { busyKey: string | null; commentDraft: string; @@ -28,7 +28,7 @@ type RequestListItemProps = { onDeleteComment: (requestId: number, commentId: number) => void | Promise; onEdit: (request: Request) => void; onOpenDetails: (request: Request) => void; - onPreviewAttachment: (preview: AttachmentPreview) => void; + onPreviewAttachment: (preview: RequestAttachmentPreview) => void; onReject: (requestId: number) => void | Promise; onSetCommentDraft: (requestId: number, value: string) => void; onToggleComments: (requestId: number) => void | Promise; diff --git a/frontend/src/components/requests/RequestListSection.tsx b/frontend/src/components/requests/RequestListSection.tsx new file mode 100644 index 00000000..ebba2b8f --- /dev/null +++ b/frontend/src/components/requests/RequestListSection.tsx @@ -0,0 +1,117 @@ +import type { RequestAttachmentPreview } from "@/hooks/useRequestsPage"; +import type { Request, RequestComment } from "@/types/api"; +import type { Dispatch, RefObject, SetStateAction } from "react"; +import { FileSignature } from "lucide-react"; +import { RequestListItem } from "./RequestListItem"; + +type RequestListSectionProps = { + busyKey: string | null; + commentDrafts: Record; + commentsMap: Record; + currentUserId?: number | null; + departmentNameMap: Map; + expandedComments: Record; + expandedRows: Record; + handleAddComment: (requestId: number) => void | Promise; + handleApprove: (requestId: number) => void | Promise; + handleCancel: (requestId: number) => void | Promise; + handleDelete: (requestId: number) => void | Promise; + handleDeleteComment: (requestId: number, commentId: number) => void | Promise; + handleReject: (requestId: number) => void | Promise; + isFinal: (status?: string) => boolean; + loadMoreRef: RefObject; + loadingMore: boolean; + nextPage: number | null; + openEdit: (request: Request) => void; + requestMenuOpenId: number | null; + requestMenuRef: RefObject; + requests: Request[]; + setAttachmentPreview: (preview: RequestAttachmentPreview | null) => void; + setCommentDrafts: Dispatch>>; + setDetailsRequest: (request: Request | null) => void; + setRequestMenuOpenId: (requestId: number | null) => void; + toggleComments: (requestId: number) => void | Promise; + toggleRow: (requestId: number) => void; +}; + +export function RequestListSection({ + busyKey, + commentDrafts, + commentsMap, + currentUserId, + departmentNameMap, + expandedComments, + expandedRows, + handleAddComment, + handleApprove, + handleCancel, + handleDelete, + handleDeleteComment, + handleReject, + isFinal, + loadMoreRef, + loadingMore, + nextPage, + openEdit, + requestMenuOpenId, + requestMenuRef, + requests, + setAttachmentPreview, + setCommentDrafts, + setDetailsRequest, + setRequestMenuOpenId, + toggleComments, + toggleRow, +}: RequestListSectionProps) { + return ( + <> +
+ {requests.length === 0 ? ( +
+ +

Заявления не найдены

+
+ ) : requests.map((request) => ( + setCommentDrafts((prev) => ({ ...prev, [requestId]: value }))} + onToggleComments={toggleComments} + onToggleMenu={setRequestMenuOpenId} + onToggleRow={toggleRow} + request={request} + rowOpen={Boolean(expandedRows[request.id])} + /> + ))} +
+ + {nextPage ? ( +
+ {loadingMore ? ( +
+
+ Загрузка... +
+ ) : null} +
+ ) : null} + + ); +} diff --git a/frontend/src/components/requests/RequestSwipeModePanel.tsx b/frontend/src/components/requests/RequestSwipeModePanel.tsx new file mode 100644 index 00000000..76f00ac1 --- /dev/null +++ b/frontend/src/components/requests/RequestSwipeModePanel.tsx @@ -0,0 +1,44 @@ +import type { Request } from "@/types/api"; +import { Zap } from "lucide-react"; +import SwipeApprovalMode from "./SwipeApprovalMode"; + +type RequestSwipeModePanelProps = { + actionError: string | null; + onApprove: (id: number) => Promise; + onClose: () => void; + onReject: (id: number) => Promise; + requests: Request[]; +}; + +export function RequestSwipeModePanel({ + actionError, + onApprove, + onClose, + onReject, + requests, +}: RequestSwipeModePanelProps) { + return ( +
+
+
+ +

Быстрый разбор

+
+ +
+ {actionError ?

{actionError}

: null} + +
+ ); +} diff --git a/frontend/src/hooks/useRequestsPage.ts b/frontend/src/hooks/useRequestsPage.ts index 5018942e..448f4d77 100644 --- a/frontend/src/hooks/useRequestsPage.ts +++ b/frontend/src/hooks/useRequestsPage.ts @@ -17,6 +17,11 @@ export type RequestFormState = { attachment: File | null; }; +export type RequestAttachmentPreview = { + url: string; + name: string; +}; + export const createEmptyForm = (): RequestFormState => ({ type: "", title: "", @@ -128,7 +133,7 @@ export function useRequestsPage(_userId: number | null | undefined) { const [swipeMode, setSwipeMode] = useState(false); /* UI */ - const [attachmentPreview, setAttachmentPreview] = useState<{ url: string; name: string } | null>(null); + const [attachmentPreview, setAttachmentPreview] = useState(null); const [detailsRequest, setDetailsRequest] = useState(null); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); @@ -230,6 +235,11 @@ export function useRequestsPage(_userId: number | null | undefined) { }); }, [ordering, requests, search]); + const pendingDecisionRequests = useMemo( + () => filteredRequests.filter((request) => String(request.status || "").toLowerCase() === "pending" && request.can_decide), + [filteredRequests], + ); + /* ── form ── */ const resetForm = () => setForm(createEmptyForm()); @@ -265,6 +275,17 @@ export function useRequestsPage(_userId: number | null | undefined) { setActionError(null); }; + const clearFilters = () => { + setView(""); + setTypeFilter(""); + setStatusFilter(""); + setEmployeeFilter(""); + setCreatedFromFilter(""); + setCreatedToFilter(""); + setPeriodFromFilter(""); + setPeriodToFilter(""); + }; + /* ── CRUD ── */ const handleCreateOrUpdate = async (mode: "create" | "edit", saveAs: "draft" | "submit") => { try { @@ -427,6 +448,7 @@ export function useRequestsPage(_userId: number | null | undefined) { return { /* data */ requests: filteredRequests, + pendingDecisionRequests, employees, departments, departmentNameMap, @@ -462,6 +484,7 @@ export function useRequestsPage(_userId: number | null | undefined) { filtersOpen, setFiltersOpen, ordering, setOrdering, swipeMode, setSwipeMode, + clearFilters, /* UI */ attachmentPreview, setAttachmentPreview, @@ -477,3 +500,5 @@ export function useRequestsPage(_userId: number | null | undefined) { isFinal, }; } + +export type RequestsPageController = ReturnType; diff --git a/frontend/src/hooks/useRequestsPageScreen.ts b/frontend/src/hooks/useRequestsPageScreen.ts new file mode 100644 index 00000000..99233d7c --- /dev/null +++ b/frontend/src/hooks/useRequestsPageScreen.ts @@ -0,0 +1,123 @@ +"use client"; + +import { apiClient } from "@/lib/api"; +import type { Request } from "@/types/api"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; + +type UseRequestsPageScreenParams = { + detailsRequestId?: number | null; + requests: Request[]; + setDetailsRequest: (request: Request | null) => void; +}; + +export function useRequestsPageScreen({ + detailsRequestId, + requests, + setDetailsRequest, +}: UseRequestsPageScreenParams) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const requestMenuRef = useRef(null); + const openedLinkedRequestIdRef = useRef(null); + const loadingLinkedRequestIdRef = useRef(null); + const [requestMenuOpenId, setRequestMenuOpenId] = useState(null); + const linkedRequestId = Number(searchParams.get("request") || ""); + + const clearRequestParam = () => { + if (!searchParams.get("request")) return; + const nextParams = new URLSearchParams(searchParams.toString()); + nextParams.delete("request"); + router.replace(nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname, { scroll: false }); + }; + + const closeDetailsRequest = () => { + setDetailsRequest(null); + clearRequestParam(); + }; + + useEffect(() => { + if (!linkedRequestId) { + openedLinkedRequestIdRef.current = null; + loadingLinkedRequestIdRef.current = null; + return; + } + + if (detailsRequestId === linkedRequestId) { + openedLinkedRequestIdRef.current = linkedRequestId; + loadingLinkedRequestIdRef.current = null; + return; + } + + if (openedLinkedRequestIdRef.current === linkedRequestId) { + return; + } + + const existing = requests.find((item) => item.id === linkedRequestId); + if (existing) { + openedLinkedRequestIdRef.current = linkedRequestId; + loadingLinkedRequestIdRef.current = null; + setDetailsRequest(existing); + return; + } + + if (loadingLinkedRequestIdRef.current === linkedRequestId) { + return; + } + + loadingLinkedRequestIdRef.current = linkedRequestId; + + let cancelled = false; + + apiClient.getRequest(linkedRequestId) + .then((request) => { + if (!cancelled && openedLinkedRequestIdRef.current !== linkedRequestId) { + openedLinkedRequestIdRef.current = linkedRequestId; + loadingLinkedRequestIdRef.current = null; + setDetailsRequest(request); + } + }) + .catch((error) => { + loadingLinkedRequestIdRef.current = null; + console.error("Ошибка deep-link заявления:", error); + }); + + return () => { + cancelled = true; + }; + }, [detailsRequestId, linkedRequestId, requests, setDetailsRequest]); + + useEffect(() => { + if (requestMenuOpenId === null) return; + + const handlePointerDown = (event: MouseEvent) => { + if (requestMenuRef.current && !requestMenuRef.current.contains(event.target as Node)) { + setRequestMenuOpenId(null); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setRequestMenuOpenId(null); + } + }; + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleEscape); + + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleEscape); + }; + }, [requestMenuOpenId]); + + return { + closeDetailsRequest, + requestMenuOpenId, + requestMenuRef, + setRequestMenuOpenId, + }; +} + +export type RequestsPageScreenController = ReturnType;