refactor(frontend): extract requests screen orchestration
This commit is contained in:
parent
331dc1433a
commit
8b150e270d
@ -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`
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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-поле
|
||||
|
||||
|
||||
@ -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 уже сейчас, а не как первую цель глобального переписывания.
|
||||
|
||||
## Как использовать эту карту
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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>>;
|
||||
};
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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>;
|
||||
|
||||
117
frontend/src/components/requests/RequestListSection.tsx
Normal file
117
frontend/src/components/requests/RequestListSection.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
frontend/src/components/requests/RequestSwipeModePanel.tsx
Normal file
44
frontend/src/components/requests/RequestSwipeModePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>;
|
||||
|
||||
123
frontend/src/hooks/useRequestsPageScreen.ts
Normal file
123
frontend/src/hooks/useRequestsPageScreen.ts
Normal 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>;
|
||||
Loading…
x
Reference in New Issue
Block a user