refactor(frontend): tighten requests controls contract
This commit is contained in:
parent
ba41dd2b0f
commit
61db827ae2
@ -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
|
||||
|
||||
@ -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-поле
|
||||
|
||||
|
||||
@ -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, а не как первую цель глобального переписывания.
|
||||
|
||||
## Как использовать эту карту
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user