refactor(frontend): extract requests screen orchestration

This commit is contained in:
lizerk 2026-04-13 13:11:58 +03:00
parent 331dc1433a
commit 8b150e270d
14 changed files with 396 additions and 197 deletions

View File

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

View File

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

View File

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

View File

@ -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-поле

View File

@ -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 уже сейчас, а не как первую цель глобального переписывания.
## Как использовать эту карту

View File

@ -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<HTMLDivElement | null>(null);
const openedLinkedRequestIdRef = useRef<number | null>(null);
const loadingLinkedRequestIdRef = useRef<number | null>(null);
const [requestMenuOpenId, setRequestMenuOpenId] = useState<number | null>(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 (
<AppShell>
@ -154,15 +51,14 @@ function RequestsPageContent() {
<div className="app-feedback-danger rounded-2xl p-6 text-center"><p className="text-sm">{h.error}</p></div>
) : (
<section className="app-surface rounded-2xl p-4">
{h.swipeMode && pendingDecisionRequests.length > 0 ? (
<div>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2"><Zap size={14} className="text-amber-500" /><p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Быстрый разбор</p></div>
<button type="button" onClick={() => h.setSwipeMode(false)} className="app-action-secondary rounded-lg px-3 py-1.5 text-xs font-medium">Обычный режим</button>
</div>
{h.actionError && <p className="app-feedback-danger mb-3 rounded-lg px-3 py-2 text-sm">{h.actionError}</p>}
<SwipeApprovalMode requests={pendingDecisionRequests} onApprove={h.handleApprove} onReject={h.handleReject} onClose={() => h.setSwipeMode(false)} />
</div>
{h.swipeMode && h.pendingDecisionRequests.length > 0 ? (
<RequestSwipeModePanel
actionError={h.actionError}
onApprove={h.handleApprove}
onClose={() => h.setSwipeMode(false)}
onReject={h.handleReject}
requests={h.pendingDecisionRequests}
/>
) : (
<>
<RequestListControls
@ -173,16 +69,7 @@ function RequestsPageContent() {
employeeFilter={h.employeeFilter}
employees={h.employees}
filtersOpen={h.filtersOpen}
onClearFilters={() => {
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}
/>
<div className="space-y-3">
{h.requests.length === 0 ? (
<div className="app-surface-muted rounded-xl p-8 text-center"><FileSignature size={22} className="app-text-muted mx-auto mb-2" /><p className="app-text-muted text-sm">Заявления не найдены</p></div>
) : h.requests.map((item) => (
<RequestListItem
key={item.id}
busyKey={h.busyKey}
commentDraft={h.commentDrafts[item.id] || ""}
comments={h.commentsMap[item.id] || []}
commentsOpen={Boolean(h.expandedComments[item.id])}
currentUserId={user?.id}
departmentNameMap={h.departmentNameMap}
isFinal={h.isFinal}
isMenuOpen={requestMenuOpenId === item.id}
menuRef={requestMenuOpenId === item.id ? requestMenuRef : null}
onAddComment={h.handleAddComment}
onApprove={h.handleApprove}
onCancel={h.handleCancel}
onDelete={h.handleDelete}
onDeleteComment={h.handleDeleteComment}
onEdit={h.openEdit}
onOpenDetails={h.setDetailsRequest}
onPreviewAttachment={h.setAttachmentPreview}
onReject={h.handleReject}
onSetCommentDraft={(requestId, value) => h.setCommentDrafts((prev) => ({ ...prev, [requestId]: value }))}
onToggleComments={h.toggleComments}
onToggleMenu={setRequestMenuOpenId}
onToggleRow={h.toggleRow}
request={item}
rowOpen={Boolean(h.expandedRows[item.id])}
/>
))}
</div>
{h.nextPage && <div ref={h.loadMoreRef} className="mt-4 flex justify-center py-4">{h.loadingMore && <div className="app-text-muted flex items-center gap-2 text-sm"><div className="h-4 w-4 animate-spin rounded-full border-2 border-[var(--border-strong)] border-t-[var(--accent-primary)]" /><span>Загрузка...</span></div>}</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}
openEdit={h.openEdit}
requestMenuOpenId={screen.requestMenuOpenId}
requestMenuRef={screen.requestMenuRef}
requests={h.requests}
setAttachmentPreview={h.setAttachmentPreview}
setCommentDrafts={h.setCommentDrafts}
setDetailsRequest={h.setDetailsRequest}
setRequestMenuOpenId={screen.setRequestMenuOpenId}
toggleComments={h.toggleComments}
toggleRow={h.toggleRow}
/>
</>
)}
</section>
@ -249,7 +129,7 @@ function RequestsPageContent() {
<RequestDetailModal
currentUserId={user?.id}
departmentNameMap={h.departmentNameMap}
onClose={closeDetailsRequest}
onClose={screen.closeDetailsRequest}
onPreviewAttachment={h.setAttachmentPreview}
request={h.detailsRequest}
/>

View File

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

View File

@ -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<void>;
setForm: Dispatch<SetStateAction<RequestFormState>>;
};

View File

@ -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<number, string>;
onClose: () => void;
onPreviewAttachment: (preview: AttachmentPreview) => void;
onPreviewAttachment: (preview: RequestAttachmentPreview) => void;
request: Request | null;
};

View File

@ -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<void>;
onEdit: (request: Request) => void;
onOpenDetails: (request: Request) => void;
onPreviewAttachment: (preview: AttachmentPreview) => void;
onPreviewAttachment: (preview: RequestAttachmentPreview) => void;
onReject: (requestId: number) => void | Promise<void>;
onSetCommentDraft: (requestId: number, value: string) => void;
onToggleComments: (requestId: number) => void | Promise<void>;

View File

@ -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<number, string>;
commentsMap: Record<number, RequestComment[]>;
currentUserId?: number | null;
departmentNameMap: Map<number, string>;
expandedComments: Record<number, boolean>;
expandedRows: Record<number, boolean>;
handleAddComment: (requestId: number) => void | Promise<void>;
handleApprove: (requestId: number) => void | Promise<void>;
handleCancel: (requestId: number) => void | Promise<void>;
handleDelete: (requestId: number) => void | Promise<void>;
handleDeleteComment: (requestId: number, commentId: number) => void | Promise<void>;
handleReject: (requestId: number) => void | Promise<void>;
isFinal: (status?: string) => boolean;
loadMoreRef: RefObject<HTMLDivElement | null>;
loadingMore: boolean;
nextPage: number | null;
openEdit: (request: Request) => void;
requestMenuOpenId: number | null;
requestMenuRef: RefObject<HTMLDivElement | null>;
requests: Request[];
setAttachmentPreview: (preview: RequestAttachmentPreview | null) => void;
setCommentDrafts: Dispatch<SetStateAction<Record<number, string>>>;
setDetailsRequest: (request: Request | null) => void;
setRequestMenuOpenId: (requestId: number | null) => void;
toggleComments: (requestId: number) => void | Promise<void>;
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 (
<>
<div className="space-y-3">
{requests.length === 0 ? (
<div className="app-surface-muted rounded-xl p-8 text-center">
<FileSignature size={22} className="app-text-muted mx-auto mb-2" />
<p className="app-text-muted text-sm">Заявления не найдены</p>
</div>
) : requests.map((request) => (
<RequestListItem
key={request.id}
busyKey={busyKey}
commentDraft={commentDrafts[request.id] || ""}
comments={commentsMap[request.id] || []}
commentsOpen={Boolean(expandedComments[request.id])}
currentUserId={currentUserId}
departmentNameMap={departmentNameMap}
isFinal={isFinal}
isMenuOpen={requestMenuOpenId === request.id}
menuRef={requestMenuOpenId === request.id ? requestMenuRef : null}
onAddComment={handleAddComment}
onApprove={handleApprove}
onCancel={handleCancel}
onDelete={handleDelete}
onDeleteComment={handleDeleteComment}
onEdit={openEdit}
onOpenDetails={setDetailsRequest}
onPreviewAttachment={setAttachmentPreview}
onReject={handleReject}
onSetCommentDraft={(requestId, value) => setCommentDrafts((prev) => ({ ...prev, [requestId]: value }))}
onToggleComments={toggleComments}
onToggleMenu={setRequestMenuOpenId}
onToggleRow={toggleRow}
request={request}
rowOpen={Boolean(expandedRows[request.id])}
/>
))}
</div>
{nextPage ? (
<div ref={loadMoreRef} className="mt-4 flex justify-center py-4">
{loadingMore ? (
<div className="app-text-muted flex items-center gap-2 text-sm">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-[var(--border-strong)] border-t-[var(--accent-primary)]" />
<span>Загрузка...</span>
</div>
) : null}
</div>
) : null}
</>
);
}

View File

@ -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<void>;
onClose: () => void;
onReject: (id: number) => Promise<void>;
requests: Request[];
};
export function RequestSwipeModePanel({
actionError,
onApprove,
onClose,
onReject,
requests,
}: RequestSwipeModePanelProps) {
return (
<div>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Zap size={14} className="text-amber-500" />
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Быстрый разбор</p>
</div>
<button
type="button"
onClick={onClose}
className="app-action-secondary rounded-lg px-3 py-1.5 text-xs font-medium"
>
Обычный режим
</button>
</div>
{actionError ? <p className="app-feedback-danger mb-3 rounded-lg px-3 py-2 text-sm">{actionError}</p> : null}
<SwipeApprovalMode
requests={requests}
onApprove={onApprove}
onReject={onReject}
onClose={onClose}
/>
</div>
);
}

View File

@ -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<RequestAttachmentPreview | null>(null);
const [detailsRequest, setDetailsRequest] = useState<Request | null>(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<typeof useRequestsPage>;

View File

@ -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<HTMLDivElement | null>(null);
const openedLinkedRequestIdRef = useRef<number | null>(null);
const loadingLinkedRequestIdRef = useRef<number | null>(null);
const [requestMenuOpenId, setRequestMenuOpenId] = useState<number | null>(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<typeof useRequestsPageScreen>;