refactor(frontend): tighten requests controls contract

This commit is contained in:
lizerk 2026-04-13 13:23:16 +03:00
parent ba41dd2b0f
commit 61db827ae2
6 changed files with 300 additions and 234 deletions

View File

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

View File

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

View File

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

View File

@ -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 (
<AppShell>
@ -62,35 +99,9 @@ function RequestsPageContent() {
) : (
<>
<RequestListControls
actionError={h.actionError}
actionSuccess={h.actionSuccess}
createdFromFilter={h.createdFromFilter}
createdToFilter={h.createdToFilter}
employeeFilter={h.employeeFilter}
employees={h.employees}
filtersOpen={h.filtersOpen}
onClearFilters={h.clearFilters}
onOpenCreate={h.openCreate}
onSetCreatedFromFilter={h.setCreatedFromFilter}
onSetCreatedToFilter={h.setCreatedToFilter}
onSetEmployeeFilter={h.setEmployeeFilter}
onSetOrdering={h.setOrdering}
onSetPeriodFromFilter={h.setPeriodFromFilter}
onSetPeriodToFilter={h.setPeriodToFilter}
onSetSearch={h.setSearch}
onSetStatusFilter={h.setStatusFilter}
onSetTypeFilter={h.setTypeFilter}
onSetView={h.setView}
onStartSwipeMode={() => 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}
/>
<RequestListSection
busyKey={h.busyKey}

View File

@ -1,30 +1,16 @@
import { orderingOptions, requestTypeLabels, statusMeta } from "@/hooks/useRequestsPage";
import { displayUserName } from "@/lib/shared";
import type { User } from "@/types/api";
import { ArrowUpDown, ChevronDown, Filter, Plus, Search, Zap } from "lucide-react";
import { ArrowUpDown, ChevronDown, Filter, Plus, Search, X, Zap } from "lucide-react";
type RequestListControlsProps = {
actionError: string | null;
actionSuccess: string | null;
type RequestListControlsState = {
activeFiltersCount: number;
createdFromFilter: string;
createdToFilter: string;
employeeFilter: string;
employees: User[];
filtersOpen: boolean;
onClearFilters: () => 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 (
<>
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<div className="mb-4 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Заявления</p>
{pendingDecisionCount > 0 ? (
<button
type="button"
onClick={onStartSwipeMode}
className="app-feedback-warning group flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium transition"
title="Быстрый разбор"
>
<Zap size={11} className="transition group-hover:text-amber-700" />
</button>
) : null}
</div>
<button
type="button"
onClick={onOpenCreate}
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium"
>
<Plus size={14} /> Создать заявление
</button>
</div>
{actionError ? <p className="app-feedback-danger mb-3 rounded-lg px-3 py-2 text-sm">{actionError}</p> : null}
{actionSuccess ? <p className="app-feedback-success mb-3 rounded-lg px-3 py-2 text-sm">{actionSuccess}</p> : null}
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1">
<Search size={16} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={search}
onChange={(event) => onSetSearch(event.target.value)}
placeholder="Поиск по заявлениям"
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
/>
</div>
<button
type="button"
title="Фильтры"
onClick={onToggleFilters}
className={`relative inline-flex items-center justify-center rounded-lg p-2.5 transition ${filtersOpen ? "app-selected app-accent-text" : "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"}`}
>
<Filter size={16} />
{activeFiltersCount > 0 ? (
<span className="app-counter absolute -right-1 -top-1 flex h-4 min-w-4 px-1 text-[10px] font-bold">
{activeFiltersCount}
{state.pendingDecisionCount > 0 ? (
<span className="app-badge inline-flex rounded-full px-2.5 py-1 text-xs font-medium">
{state.pendingDecisionCount} на рассмотрении
</span>
) : null}
</button>
</div>
<div className="relative w-[148px] shrink-0">
<ArrowUpDown size={15} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
<select
value={ordering}
onChange={(event) => onSetOrdering(event.target.value)}
className="app-select w-full appearance-none rounded-lg py-2.5 pl-9 pr-8 text-xs font-medium"
aria-label="Сортировка"
<div className="flex flex-wrap items-center gap-2">
{state.pendingDecisionCount > 0 ? (
<button
type="button"
onClick={actions.openSwipeMode}
className="app-feedback-warning inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium transition"
title="Быстрый разбор"
>
<Zap size={14} />
Быстрый разбор
</button>
) : null}
<button
type="button"
onClick={actions.openCreate}
className="app-action-primary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium"
>
{orderingOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<ChevronDown size={14} className="app-text-muted pointer-events-none absolute right-3 top-1/2 -translate-y-1/2" />
<Plus size={14} />
Создать заявление
</button>
</div>
</div>
{filtersOpen ? (
<div className="app-surface-muted mb-3 flex flex-col gap-2 rounded-xl p-3">
<select
value={view}
onChange={(event) => onSetView(event.target.value as "" | "mine" | "addressed")}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Все заявления</option>
<option value="mine">Мои заявления</option>
<option value="addressed">Адресованные мне</option>
</select>
{feedback.actionError ? (
<p className="app-feedback-danger rounded-lg px-3 py-2 text-sm">{feedback.actionError}</p>
) : null}
{feedback.actionSuccess ? (
<p className="app-feedback-success rounded-lg px-3 py-2 text-sm">{feedback.actionSuccess}</p>
) : null}
<select
value={typeFilter}
onChange={(event) => onSetTypeFilter(event.target.value)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Тип заявления</option>
{Object.entries(requestTypeLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<select
value={statusFilter}
onChange={(event) => onSetStatusFilter(event.target.value)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Статус заявления</option>
{Object.entries(statusMeta).map(([value, meta]) => (
<option key={value} value={value}>{meta.label}</option>
))}
</select>
<select
value={employeeFilter}
onChange={(event) => onSetEmployeeFilter(event.target.value)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Все сотрудники</option>
{employees.map((employee) => (
<option key={employee.id} value={employee.id}>{displayUserName(employee)}</option>
))}
</select>
<div className="space-y-1.5">
<label className="app-text-muted px-1 text-xs font-medium">Дата создания</label>
<div className="flex gap-2">
<input
type="date"
value={createdFromFilter}
onChange={(event) => onSetCreatedFromFilter(event.target.value)}
className="app-input flex-1 rounded-lg px-3 py-2 text-sm"
/>
<input
type="date"
value={createdToFilter}
onChange={(event) => onSetCreatedToFilter(event.target.value)}
className="app-input flex-1 rounded-lg px-3 py-2 text-sm"
/>
</div>
<section className="app-surface-muted rounded-xl p-3">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
<div className="relative flex-1">
<Search
size={16}
className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2"
/>
<input
value={state.search}
onChange={(event) => actions.setSearch(event.target.value)}
placeholder="Поиск по заявлениям"
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
/>
</div>
<div className="space-y-1.5">
<label className="app-text-muted px-1 text-xs font-medium">Период заявления</label>
<div className="flex gap-2">
<input
type="date"
value={periodFromFilter}
onChange={(event) => onSetPeriodFromFilter(event.target.value)}
className="app-input flex-1 rounded-lg px-3 py-2 text-sm"
/>
<input
type="date"
value={periodToFilter}
onChange={(event) => onSetPeriodToFilter(event.target.value)}
className="app-input flex-1 rounded-lg px-3 py-2 text-sm"
/>
</div>
</div>
{activeFiltersCount > 0 ? (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={onClearFilters}
className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium transition"
title="Фильтры"
onClick={actions.toggleFilters}
className={`inline-flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm font-medium transition ${state.filtersOpen ? "app-selected app-accent-text" : "app-surface app-text-muted hover:bg-[var(--surface-tertiary)]"}`}
>
Очистить фильтры
<Filter size={16} />
<span>Фильтры</span>
{state.activeFiltersCount > 0 ? (
<span className="app-counter flex h-5 min-w-5 items-center justify-center px-1 text-[10px] font-bold">
{state.activeFiltersCount}
</span>
) : null}
</button>
) : null}
<div className="relative min-w-[168px] flex-1 sm:flex-none">
<ArrowUpDown
size={15}
className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2"
/>
<select
value={state.ordering}
onChange={(event) => actions.setOrdering(event.target.value)}
className="app-select w-full appearance-none rounded-lg py-2.5 pl-9 pr-8 text-sm font-medium"
aria-label="Сортировка"
>
{orderingOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ChevronDown
size={14}
className="app-text-muted pointer-events-none absolute right-3 top-1/2 -translate-y-1/2"
/>
</div>
</div>
</div>
) : null}
</>
{state.filtersOpen ? (
<div className="mt-3 space-y-3 border-t border-[var(--border-subtle)] pt-3">
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-4">
<select
value={state.view}
onChange={(event) => actions.setView(event.target.value as "" | "mine" | "addressed")}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Все заявления</option>
<option value="mine">Мои заявления</option>
<option value="addressed">Адресованные мне</option>
</select>
<select
value={state.typeFilter}
onChange={(event) => actions.setTypeFilter(event.target.value)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Тип заявления</option>
{Object.entries(requestTypeLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<select
value={state.statusFilter}
onChange={(event) => actions.setStatusFilter(event.target.value)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Статус заявления</option>
{Object.entries(statusMeta).map(([value, meta]) => (
<option key={value} value={value}>
{meta.label}
</option>
))}
</select>
<select
value={state.employeeFilter}
onChange={(event) => actions.setEmployeeFilter(event.target.value)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Все сотрудники</option>
{state.employees.map((employee) => (
<option key={employee.id} value={employee.id}>
{displayUserName(employee)}
</option>
))}
</select>
</div>
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2">
<div className="space-y-1.5">
<label className="app-text-muted px-1 text-xs font-medium">Дата создания</label>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<input
type="date"
value={state.createdFromFilter}
onChange={(event) => actions.setCreatedFromFilter(event.target.value)}
className="app-input rounded-lg px-3 py-2 text-sm"
/>
<input
type="date"
value={state.createdToFilter}
onChange={(event) => actions.setCreatedToFilter(event.target.value)}
className="app-input rounded-lg px-3 py-2 text-sm"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="app-text-muted px-1 text-xs font-medium">Период заявления</label>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<input
type="date"
value={state.periodFromFilter}
onChange={(event) => actions.setPeriodFromFilter(event.target.value)}
className="app-input rounded-lg px-3 py-2 text-sm"
/>
<input
type="date"
value={state.periodToFilter}
onChange={(event) => actions.setPeriodToFilter(event.target.value)}
className="app-input rounded-lg px-3 py-2 text-sm"
/>
</div>
</div>
</div>
{state.hasActiveFilters ? (
<div className="flex justify-end">
<button
type="button"
onClick={actions.clearFilters}
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium transition"
>
<X size={14} />
Очистить фильтры
</button>
</div>
) : null}
</div>
) : null}
</section>
</div>
);
}

View File

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