From 2cfa1099f31a3f681ec0abcdc5f1ddc19de5f244 Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 13 Apr 2026 13:17:34 +0300 Subject: [PATCH] refactor(frontend): normalize requests list shell --- docs/guides/frontend/04-PATTERNS.md | 1 + .../guides/frontend/06-COMPONENT-INVENTORY.md | 2 +- docs/guides/frontend/07-DEVELOPER-WORKFLOW.md | 1 + docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md | 4 +- frontend/src/app/requests/page.tsx | 2 +- .../components/requests/RequestListItem.tsx | 460 ++++++++++-------- .../requests/RequestListSection.tsx | 8 +- frontend/src/hooks/useRequestsPage.ts | 6 +- 8 files changed, 279 insertions(+), 205 deletions(-) diff --git a/docs/guides/frontend/04-PATTERNS.md b/docs/guides/frontend/04-PATTERNS.md index 947bedbd..c7edbb18 100644 --- a/docs/guides/frontend/04-PATTERNS.md +++ b/docs/guides/frontend/04-PATTERNS.md @@ -33,6 +33,7 @@ Для `filter form` дополнительно: - toolbar и фильтрующий блок лучше держать отдельным feature-компонентом, если page-level файл уже управляет списком, detail modal и compose flow одновременно +- если filter-toolbar уже соседствует с плотным list shell, сам list shell тоже должен иметь явную anatomy: action rail, content column и отдельные secondary panels вместо одного бесформенного nested block ### Dropdown-like UX diff --git a/docs/guides/frontend/06-COMPONENT-INVENTORY.md b/docs/guides/frontend/06-COMPONENT-INVENTORY.md index 1dbcffad..5c6a6c3b 100644 --- a/docs/guides/frontend/06-COMPONENT-INVENTORY.md +++ b/docs/guides/frontend/06-COMPONENT-INVENTORY.md @@ -179,7 +179,7 @@ - compose-like form pattern - detail modal с participant-heavy content - page-level toolbar/filter block, который вынесен из страницы в feature component -- dense list row с actions/comments/expandable secondary content +- dense list row с actions/comments/expandable secondary content, где row anatomy разложена на action rail, content column и отдельные detail/comments panels - feature container для list shell, который собирает rows + empty state + load more - отдельный mode-panel для alternate flow вроде swipe-review - attachment preview modal как отдельный feature block, а не хвост page-level JSX diff --git a/docs/guides/frontend/07-DEVELOPER-WORKFLOW.md b/docs/guides/frontend/07-DEVELOPER-WORKFLOW.md index 410c790f..a7edab25 100644 --- a/docs/guides/frontend/07-DEVELOPER-WORKFLOW.md +++ b/docs/guides/frontend/07-DEVELOPER-WORKFLOW.md @@ -78,6 +78,7 @@ - то же касается 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 +- dense row после такого выноса не должна оставаться одним бесформенным JSX-комком: отдельно собираются action rail, main content и secondary panels вроде details/comments ## 5. Как делать dropdown-поле diff --git a/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md b/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md index 202161a6..7774968a 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, screen orchestration вынесена в hook, list/swipe flow собраны в feature containers | visual debt остаётся в отдельных локальных визуальных решениях; зона ещё не стала полным target-state | использовать как референс для разделения orchestration/UI и participant-heavy request flows, затем отдельно чистить локальную визуальную неоднородность | +| `requests` | `mixed` | compose-настроение, participant-only логика, UI вокруг `can_decide`, compose/list/detail/controls/preview разведены по feature components, screen orchestration вынесена в hook, list/swipe flow собраны в feature containers, dense row приведён к явной anatomy с отдельными detail/comments panels | visual debt сузился, но ещё остаются image warnings и контракт controls block | использовать как референс для разделения orchestration/UI, participant-heavy request flows и anatomy плотной строки, затем добить image/controls cleanup | | `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 блоков, screen-specific orchestration hooks и feature containers уже сейчас, а не как первую цель глобального переписывания. +`requests` лучше использовать как референс для compose-настроения, выделения feature-level workspace/list-detail/controls блоков, screen-specific orchestration hooks, feature containers и anatomy плотной строки уже сейчас, а не как первую цель глобального переписывания. ## Как использовать эту карту diff --git a/frontend/src/app/requests/page.tsx b/frontend/src/app/requests/page.tsx index 4b4b2a5c..a7077ed0 100644 --- a/frontend/src/app/requests/page.tsx +++ b/frontend/src/app/requests/page.tsx @@ -115,7 +115,7 @@ function RequestsPageContent() { requestMenuRef={screen.requestMenuRef} requests={h.requests} setAttachmentPreview={h.setAttachmentPreview} - setCommentDrafts={h.setCommentDrafts} + onSetCommentDraft={h.setCommentDraft} setDetailsRequest={h.setDetailsRequest} setRequestMenuOpenId={screen.setRequestMenuOpenId} toggleComments={h.toggleComments} diff --git a/frontend/src/components/requests/RequestListItem.tsx b/frontend/src/components/requests/RequestListItem.tsx index 6334fff7..b00e8cac 100644 --- a/frontend/src/components/requests/RequestListItem.tsx +++ b/frontend/src/components/requests/RequestListItem.tsx @@ -5,10 +5,10 @@ import { statusMeta, type RequestAttachmentPreview, } from "@/hooks/useRequestsPage"; -import { displayUserName, formatDate, userProfileLink } from "@/lib/shared"; -import type { Request, RequestComment } from "@/types/api"; +import { displayUserName, formatDate, formatDateTime, userProfileLink } from "@/lib/shared"; +import type { Request, RequestComment, User } from "@/types/api"; import { Ban, ChevronDown, ChevronRight, MessageSquare, Paperclip, Pencil, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"; -import type { Ref } from "react"; +import type { ReactNode, Ref } from "react"; import { RequestUserBadge } from "./RequestUserBadge"; type RequestListItemProps = { @@ -38,6 +38,77 @@ type RequestListItemProps = { rowOpen: boolean; }; +type RequestMetaFieldProps = { + label: string; + value: ReactNode; +}; + +type RequestAudienceRowProps = { + count?: number; + currentUserId?: number | null; + emptyText: string; + label: string; + people: User[]; +}; + +function RequestMetaField({ label, value }: RequestMetaFieldProps) { + return ( +
+

{label}

+
{value}
+
+ ); +} + +function RequestAudienceRow({ + count, + currentUserId, + emptyText, + label, + people, +}: RequestAudienceRowProps) { + return ( +
+

{label}

+ {people.length > 0 ? ( +
+ {people.slice(0, 2).map((person) => ( + + ))} + {people.length > 2 ? ( + + +{people.length - 2} + + ) : null} +
+ ) : ( +

{count ? count : emptyText}

+ )} +
+ ); +} + +function renderDecisionMaker(decisionMaker: User | undefined, currentUserId?: number | null) { + if (!decisionMaker) { + return -; + } + + const decisionMakerLink = userProfileLink(decisionMaker, currentUserId); + const decisionMakerName = displayUserName(decisionMaker); + + return decisionMakerLink ? ( + + {decisionMakerName} + + ) : ( + {decisionMakerName} + ); +} + export function RequestListItem({ busyKey, commentDraft, @@ -66,10 +137,10 @@ export function RequestListItem({ }: RequestListItemProps) { const requestAuthor = request.employee || request.created_by; const authorName = displayUserName(requestAuthor); - const statusKey = String(request.status || "").toLowerCase(); - const typeKey = String(request.type || request.request_type || "").toLowerCase(); - const status = statusMeta[statusKey] ?? defaultStatusMeta; const authorLink = requestAuthor ? userProfileLink(requestAuthor, currentUserId) : null; + const statusKey = String(request.status || "").toLowerCase(); + const status = statusMeta[statusKey] ?? 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 canProcess = Boolean(statusKey === "pending" && request.can_decide); @@ -79,49 +150,58 @@ export function RequestListItem({ const canDelete = Boolean(isAuthor && !isFinal(statusKey)); const hasSecondaryActions = canCancel || canEdit || canDelete; const canComment = statusKey !== "draft"; + const summary = request.comment || request.description; + const commentCount = request.comments_count ?? comments.length; + const trimmedCommentDraft = commentDraft.trim(); + const recipients = request.recipients || []; + const ccUsers = request.cc_users || []; + const decisionMaker = request.approver || request.assigned_to; const departmentLabels = (request.departments || []) .map((id) => departmentNameMap.get(Number(id)) || `Отдел #${id}`) .join(", "); - const recipients = request.recipients || []; - const ccUsers = request.cc_users || []; - const summary = request.comment || request.description; const attachmentUrl = request.attachment_url || request.attachment || ""; const attachmentName = attachmentUrl ? decodeURIComponent(attachmentUrl.split("/").pop() || "Вложение") : ""; + const hasExpandedPanels = rowOpen || commentsOpen; return ( -
-
-
-
+
+
+
+
-
+
-
-
+
+
{authorLink ? ( {requestAuthor?.avatar ? ( @@ -145,24 +225,36 @@ export function RequestListItem({ {authorName}
)} -
- - - -
- - Период: {request.date_from ? formatDate(request.date_from) : "—"} - {request.date_to ? ` — ${formatDate(request.date_to)}` : ""} + + {typeLabel}
+ +
+ + +
+ + Период: {request.date_from ? formatDate(request.date_from) : "-"} + {request.date_to ? ` - ${formatDate(request.date_to)}` : ""} + + Создано: {formatDate(request.created_at) || "-"} + {departmentLabels ? Отделы: {departmentLabels} : null} +
+
+ + {summary ? ( +

+ {summary} +

+ ) : null}
-
+
{status.label} @@ -231,174 +323,150 @@ export function RequestListItem({
) : null}
+ + {canProcess ? ( +
+ + +
+ ) : null}
- {summary ? ( -

- {summary} -

- ) : null} + {hasExpandedPanels ? ( +
+ {rowOpen ? ( +
+
+

Подробности

+ {attachmentUrl ? ( + + ) : null} +
- {canProcess ? ( -
- - - - +
+
+
+ + + + -} + /> +
+
+ +
+
+ + +
+
+
+
+ ) : null} + + {commentsOpen ? ( +
+
+

Комментарии

+ + {commentCount} + +
+ +
+ {comments.length === 0 ? ( +

Комментариев пока нет

+ ) : comments.map((comment) => ( +
+
+ {displayUserName(comment.author)} +
+ {formatDateTime(comment.created_at) || "-"} + {Boolean(comment.author?.id && currentUserId === comment.author.id) ? ( + + ) : null} +
+
+

{comment.text}

+
+ ))} +
+ +
+ onSetCommentDraft(request.id, event.target.value)} + placeholder="Добавить комментарий" + className="app-input flex-1 rounded-lg px-3 py-2.5 text-sm" + /> + +
+
+ ) : null}
) : null}
- - {(rowOpen || commentsOpen) ? ( -
- {rowOpen ? ( -
-
-
- Принял решение:{" "} - {(() => { - const approver = request.approver || request.assigned_to; - if (!approver) return —; - const approverLink = userProfileLink(approver, currentUserId); - const approverName = displayUserName(approver); - return approverLink - ? {approverName} - : {approverName}; - })()} -
-
- Создано:{" "} - {formatDate(request.created_at) || "—"} -
-
- Обновлено:{" "} - {formatDate(request.updated_at) || "—"} -
- {departmentLabels ? ( -
- Отделы:{" "} - {departmentLabels} -
- ) : null} -
- -
-
- Получатели: -
- {recipients.slice(0, 2).map((recipient) => ( - - ))} - {recipients.length === 0 ? ( - {request.recipient_count ?? 0} - ) : null} - {recipients.length > 2 ? ( - +{recipients.length - 2} - ) : null} -
-
- -
- В копии: -
- {ccUsers.slice(0, 2).map((ccUser) => ( - - ))} - {ccUsers.length === 0 ? — : null} - {ccUsers.length > 2 ? ( - +{ccUsers.length - 2} - ) : null} -
-
- - {attachmentUrl ? ( -
- -
- ) : null} -
-
- ) : null} - - {commentsOpen ? ( -
-
- {comments.length === 0 ? ( -

Комментариев пока нет

- ) : comments.map((comment) => ( -
-
- {displayUserName(comment.author)} -
- {formatDate(comment.created_at)} - {Boolean(comment.author?.id && currentUserId === comment.author.id) ? ( - - ) : null} -
-
-

{comment.text}

-
- ))} -
-
- onSetCommentDraft(request.id, event.target.value)} - placeholder="Добавить комментарий" - className="app-input flex-1 rounded-lg px-3 py-2 text-xs" - /> - -
-
- ) : null} -
- ) : null}
); diff --git a/frontend/src/components/requests/RequestListSection.tsx b/frontend/src/components/requests/RequestListSection.tsx index ebba2b8f..dc7d946c 100644 --- a/frontend/src/components/requests/RequestListSection.tsx +++ b/frontend/src/components/requests/RequestListSection.tsx @@ -1,6 +1,6 @@ import type { RequestAttachmentPreview } from "@/hooks/useRequestsPage"; import type { Request, RequestComment } from "@/types/api"; -import type { Dispatch, RefObject, SetStateAction } from "react"; +import type { RefObject } from "react"; import { FileSignature } from "lucide-react"; import { RequestListItem } from "./RequestListItem"; @@ -26,8 +26,8 @@ type RequestListSectionProps = { requestMenuOpenId: number | null; requestMenuRef: RefObject; requests: Request[]; + onSetCommentDraft: (requestId: number, value: string) => void; setAttachmentPreview: (preview: RequestAttachmentPreview | null) => void; - setCommentDrafts: Dispatch>>; setDetailsRequest: (request: Request | null) => void; setRequestMenuOpenId: (requestId: number | null) => void; toggleComments: (requestId: number) => void | Promise; @@ -56,8 +56,8 @@ export function RequestListSection({ requestMenuOpenId, requestMenuRef, requests, + onSetCommentDraft, setAttachmentPreview, - setCommentDrafts, setDetailsRequest, setRequestMenuOpenId, toggleComments, @@ -92,7 +92,7 @@ export function RequestListSection({ onOpenDetails={setDetailsRequest} onPreviewAttachment={setAttachmentPreview} onReject={handleReject} - onSetCommentDraft={(requestId, value) => setCommentDrafts((prev) => ({ ...prev, [requestId]: value }))} + onSetCommentDraft={onSetCommentDraft} onToggleComments={toggleComments} onToggleMenu={setRequestMenuOpenId} onToggleRow={toggleRow} diff --git a/frontend/src/hooks/useRequestsPage.ts b/frontend/src/hooks/useRequestsPage.ts index 448f4d77..03d2482c 100644 --- a/frontend/src/hooks/useRequestsPage.ts +++ b/frontend/src/hooks/useRequestsPage.ts @@ -431,6 +431,10 @@ export function useRequestsPage(_userId: number | null | undefined) { finally { setBusyKey(null); } }; + const setCommentDraft = useCallback((requestId: number, value: string) => { + setCommentDrafts((prev) => ({ ...prev, [requestId]: value })); + }, []); + const isFinal = (status?: string) => ["approved", "rejected", "cancelled"].includes(String(status || "").toLowerCase()); const loadMoreRef = useRef(null); @@ -456,7 +460,7 @@ export function useRequestsPage(_userId: number | null | undefined) { expandedRows, expandedComments, commentDrafts, - setCommentDrafts, + setCommentDraft, /* form */ form,