From 61db827ae29f5d2062a2ac39904b85e5a8e9c72d Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 13 Apr 2026 13:23:16 +0300 Subject: [PATCH] refactor(frontend): tighten requests controls contract --- .../guides/frontend/06-COMPONENT-INVENTORY.md | 1 + docs/guides/frontend/07-DEVELOPER-WORKFLOW.md | 1 + docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md | 4 +- frontend/src/app/requests/page.tsx | 69 +-- .../requests/RequestListControls.tsx | 426 +++++++++--------- frontend/src/hooks/useRequestsPage.ts | 33 ++ 6 files changed, 300 insertions(+), 234 deletions(-) diff --git a/docs/guides/frontend/06-COMPONENT-INVENTORY.md b/docs/guides/frontend/06-COMPONENT-INVENTORY.md index b2978e16..dee5b3bb 100644 --- a/docs/guides/frontend/06-COMPONENT-INVENTORY.md +++ b/docs/guides/frontend/06-COMPONENT-INVENTORY.md @@ -180,6 +180,7 @@ - compose-like form pattern - detail modal с participant-heavy content - page-level toolbar/filter block, который вынесен из страницы в feature component +- feature-local controls contract, где toolbar/filter state и actions сгруппированы вместо длинного списка разрозненных props - 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 diff --git a/docs/guides/frontend/07-DEVELOPER-WORKFLOW.md b/docs/guides/frontend/07-DEVELOPER-WORKFLOW.md index a7edab25..cbe144b5 100644 --- a/docs/guides/frontend/07-DEVELOPER-WORKFLOW.md +++ b/docs/guides/frontend/07-DEVELOPER-WORKFLOW.md @@ -79,6 +79,7 @@ - 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 +- если у toolbar/filter block пропсы начинают расползаться, лучше сгруппировать feature-local `state` и `actions`, чем продолжать расширять плоский список `onSet*` и `value` ## 5. Как делать dropdown-поле diff --git a/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md b/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md index af519c0b..833f7e9c 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, dense row приведён к явной anatomy с отдельными detail/comments panels, avatar/preview media contract выровнен через local wrapper | основная незавершённость теперь в контракте controls block | использовать как референс для разделения orchestration/UI, participant-heavy request flows, anatomy плотной строки и локального media-wrapper паттерна, затем добить controls cleanup | +| `requests` | `ok` | 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, avatar/preview media contract выровнен через local wrapper, controls block собран на grouped state/actions contract | остаются только точечные follow-up улучшения, но блокирующего visual debt больше нет | использовать как референс для разделения orchestration/UI, participant-heavy request flows, anatomy плотной строки, локального media-wrapper паттерна и feature-local controls contract | | `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 и anatomy плотной строки уже сейчас, а не как первую цель глобального переписывания. +`requests` теперь можно использовать как полноценный reference для compose-настроения, feature-level workspace/list-detail/controls блоков, screen-specific orchestration hooks, feature containers, anatomy плотной строки и grouped controls contract, а не как первую цель глобального переписывания. ## Как использовать эту карту diff --git a/frontend/src/app/requests/page.tsx b/frontend/src/app/requests/page.tsx index a7077ed0..0cf6f05b 100644 --- a/frontend/src/app/requests/page.tsx +++ b/frontend/src/app/requests/page.tsx @@ -39,6 +39,43 @@ function RequestsPageContent() { requests: h.requests, setDetailsRequest: h.setDetailsRequest, }); + const requestControlsActions = { + clearFilters: h.clearFilters, + openCreate: h.openCreate, + openSwipeMode: h.openSwipeMode, + setCreatedFromFilter: h.setCreatedFromFilter, + setCreatedToFilter: h.setCreatedToFilter, + setEmployeeFilter: h.setEmployeeFilter, + setOrdering: h.setOrdering, + setPeriodFromFilter: h.setPeriodFromFilter, + setPeriodToFilter: h.setPeriodToFilter, + setSearch: h.setSearch, + setStatusFilter: h.setStatusFilter, + setTypeFilter: h.setTypeFilter, + setView: h.setView, + toggleFilters: h.toggleFilters, + }; + const requestControlsFeedback = { + actionError: h.actionError, + actionSuccess: h.actionSuccess, + }; + const requestControlsState = { + activeFiltersCount: h.activeFiltersCount, + createdFromFilter: h.createdFromFilter, + createdToFilter: h.createdToFilter, + employeeFilter: h.employeeFilter, + employees: h.employees, + filtersOpen: h.filtersOpen, + hasActiveFilters: h.hasActiveFilters, + ordering: h.ordering, + pendingDecisionCount: h.pendingDecisionCount, + periodFromFilter: h.periodFromFilter, + periodToFilter: h.periodToFilter, + search: h.search, + statusFilter: h.statusFilter, + typeFilter: h.typeFilter, + view: h.view, + }; return ( @@ -62,35 +99,9 @@ function RequestsPageContent() { ) : ( <> h.setSwipeMode(true)} - onToggleFilters={() => h.setFiltersOpen((value) => !value)} - ordering={h.ordering} - pendingDecisionCount={h.pendingDecisionRequests.length} - periodFromFilter={h.periodFromFilter} - periodToFilter={h.periodToFilter} - search={h.search} - statusFilter={h.statusFilter} - typeFilter={h.typeFilter} - view={h.view} + actions={requestControlsActions} + feedback={requestControlsFeedback} + state={requestControlsState} /> void; - onOpenCreate: () => void; - onSetCreatedFromFilter: (value: string) => void; - onSetCreatedToFilter: (value: string) => void; - onSetEmployeeFilter: (value: string) => void; - onSetOrdering: (value: string) => void; - onSetPeriodFromFilter: (value: string) => void; - onSetPeriodToFilter: (value: string) => void; - onSetSearch: (value: string) => void; - onSetStatusFilter: (value: string) => void; - onSetTypeFilter: (value: string) => void; - onSetView: (value: "" | "mine" | "addressed") => void; - onStartSwipeMode: () => void; - onToggleFilters: () => void; + hasActiveFilters: boolean; ordering: string; pendingDecisionCount: number; periodFromFilter: string; @@ -35,209 +21,243 @@ type RequestListControlsProps = { view: "" | "mine" | "addressed"; }; +type RequestListControlsFeedback = { + actionError: string | null; + actionSuccess: string | null; +}; + +type RequestListControlsActions = { + clearFilters: () => void; + openCreate: () => void; + openSwipeMode: () => void; + setCreatedFromFilter: (value: string) => void; + setCreatedToFilter: (value: string) => void; + setEmployeeFilter: (value: string) => void; + setOrdering: (value: string) => void; + setPeriodFromFilter: (value: string) => void; + setPeriodToFilter: (value: string) => void; + setSearch: (value: string) => void; + setStatusFilter: (value: string) => void; + setTypeFilter: (value: string) => void; + setView: (value: "" | "mine" | "addressed") => void; + toggleFilters: () => void; +}; + +type RequestListControlsProps = { + actions: RequestListControlsActions; + feedback: RequestListControlsFeedback; + state: RequestListControlsState; +}; + export function RequestListControls({ - actionError, - actionSuccess, - createdFromFilter, - createdToFilter, - employeeFilter, - employees, - filtersOpen, - onClearFilters, - onOpenCreate, - onSetCreatedFromFilter, - onSetCreatedToFilter, - onSetEmployeeFilter, - onSetOrdering, - onSetPeriodFromFilter, - onSetPeriodToFilter, - onSetSearch, - onSetStatusFilter, - onSetTypeFilter, - onSetView, - onStartSwipeMode, - onToggleFilters, - ordering, - pendingDecisionCount, - periodFromFilter, - periodToFilter, - search, - statusFilter, - typeFilter, - view, + actions, + feedback, + state, }: RequestListControlsProps) { - const activeFiltersCount = [ - view, - typeFilter, - statusFilter, - employeeFilter, - createdFromFilter, - createdToFilter, - periodFromFilter, - periodToFilter, - ].filter(Boolean).length; - return ( - <> -
-
+
+
+

Заявления

- {pendingDecisionCount > 0 ? ( - - ) : null} -
- -
- - {actionError ?

{actionError}

: null} - {actionSuccess ?

{actionSuccess}

: null} - -
-
- - onSetSearch(event.target.value)} - placeholder="Поиск по заявлениям" - className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm" - /> -
- - +
-
- - - + + Создать заявление +
- {filtersOpen ? ( -
- + {feedback.actionError ? ( +

{feedback.actionError}

+ ) : null} + {feedback.actionSuccess ? ( +

{feedback.actionSuccess}

+ ) : null} - - - - - - -
- -
- onSetCreatedFromFilter(event.target.value)} - className="app-input flex-1 rounded-lg px-3 py-2 text-sm" - /> - onSetCreatedToFilter(event.target.value)} - className="app-input flex-1 rounded-lg px-3 py-2 text-sm" - /> -
+
+
+
+ + actions.setSearch(event.target.value)} + placeholder="Поиск по заявлениям" + className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm" + />
-
- -
- onSetPeriodFromFilter(event.target.value)} - className="app-input flex-1 rounded-lg px-3 py-2 text-sm" - /> - onSetPeriodToFilter(event.target.value)} - className="app-input flex-1 rounded-lg px-3 py-2 text-sm" - /> -
-
- - {activeFiltersCount > 0 ? ( +
- ) : null} + +
+ + + +
+
- ) : null} - + + {state.filtersOpen ? ( +
+
+ + + + + + + +
+ +
+
+ +
+ actions.setCreatedFromFilter(event.target.value)} + className="app-input rounded-lg px-3 py-2 text-sm" + /> + actions.setCreatedToFilter(event.target.value)} + className="app-input rounded-lg px-3 py-2 text-sm" + /> +
+
+ +
+ +
+ actions.setPeriodFromFilter(event.target.value)} + className="app-input rounded-lg px-3 py-2 text-sm" + /> + actions.setPeriodToFilter(event.target.value)} + className="app-input rounded-lg px-3 py-2 text-sm" + /> +
+
+
+ + {state.hasActiveFilters ? ( +
+ +
+ ) : null} +
+ ) : null} +
+
); } diff --git a/frontend/src/hooks/useRequestsPage.ts b/frontend/src/hooks/useRequestsPage.ts index 03d2482c..d8589983 100644 --- a/frontend/src/hooks/useRequestsPage.ts +++ b/frontend/src/hooks/useRequestsPage.ts @@ -239,6 +239,30 @@ export function useRequestsPage(_userId: number | null | undefined) { () => filteredRequests.filter((request) => String(request.status || "").toLowerCase() === "pending" && request.can_decide), [filteredRequests], ); + const pendingDecisionCount = pendingDecisionRequests.length; + const activeFiltersCount = useMemo( + () => [ + view, + typeFilter, + statusFilter, + employeeFilter, + createdFromFilter, + createdToFilter, + periodFromFilter, + periodToFilter, + ].filter(Boolean).length, + [ + view, + typeFilter, + statusFilter, + employeeFilter, + createdFromFilter, + createdToFilter, + periodFromFilter, + periodToFilter, + ], + ); + const hasActiveFilters = activeFiltersCount > 0; /* ── form ── */ const resetForm = () => setForm(createEmptyForm()); @@ -268,6 +292,8 @@ export function useRequestsPage(_userId: number | null | undefined) { setCreateOpen(true); }; + const openSwipeMode = () => setSwipeMode(true); + const closeModal = () => { setCreateOpen(false); setEditingRequest(null); @@ -286,6 +312,8 @@ export function useRequestsPage(_userId: number | null | undefined) { setPeriodToFilter(""); }; + const toggleFilters = () => setFiltersOpen((value) => !value); + /* ── CRUD ── */ const handleCreateOrUpdate = async (mode: "create" | "edit", saveAs: "draft" | "submit") => { try { @@ -488,7 +516,12 @@ export function useRequestsPage(_userId: number | null | undefined) { filtersOpen, setFiltersOpen, ordering, setOrdering, swipeMode, setSwipeMode, + activeFiltersCount, + hasActiveFilters, + pendingDecisionCount, clearFilters, + toggleFilters, + openSwipeMode, /* UI */ attachmentPreview, setAttachmentPreview,