refactor(frontend): decompose requests screen

This commit is contained in:
lizerk 2026-04-13 12:56:00 +03:00
parent bac7c65062
commit 331dc1433a
21 changed files with 2771 additions and 359 deletions

View File

@ -0,0 +1,117 @@
# Frontend Guide: Overview
## Назначение
Этот guide определяет правила, по которым должны строиться новые frontend-экраны, shared-компоненты и рефакторинг существующего UI.
При конфликте между текущим состоянием кода и этим guide приоритет у guide. Если часть системы ещё не приведена к target state, это legacy-состояние, а не альтернативная норма.
## Границы
Guide покрывает:
- визуальную систему frontend
- layout-принципы
- shared UI primitives
- геометрию
- card и form patterns
- modal и dropdown contracts
Guide не покрывает:
- API-дизайн
- state management конкретной фичи
- domain-логику
- отдельные feature-спеки
## Карта frontend-проекта
### `frontend/src/app`
Роль:
- маршруты Next.js App Router
- page-level composition
- orchestration экранов
Правила:
- страницы собирают UI из shared и feature-компонентов
- страницы не должны становиться вторым слоем дизайн-системы
- повторяемый паттерн должен подниматься в `components`
- если в странице уже живут toolbar/filters, expandable rows, detail modal и preview modal, это сигнал к разрезанию на feature-компоненты
### `frontend/src/components`
Роль:
- shared UI primitives
- reusable feature-компоненты
- списки, панели, dropdowns, modals, detail blocks
Правила:
- всё, что задаёт повторяемую визуальную механику, должно жить здесь
- shared-компонент должен использовать проектный visual contract, а не изобретать новый
- если похожий primitive уже есть, его нужно расширять, а не создавать параллельный
### `frontend/src/hooks`
Роль:
- orchestration логики
- загрузка данных
- derived state
Правила:
- hook не кодирует дизайн-систему
- hook может возвращать данные для UI, но не должен становиться хранилищем визуальных классов
### `frontend/src/lib`
Роль:
- API clients
- adapters
- shared helpers
Правила:
- этот слой не должен зависеть от page-level UI
- UI-специфичные преобразования нужно держать ближе к feature-компоненту
### `frontend/src/types`
Роль:
- типы API
- shared domain contracts
Правила:
- типы описывают данные, а не визуальное оформление
- UI-значимые семантические флаги должны приходить как явные поля, а не вычисляться по role-based эвристикам в нескольких местах
## Текущее состояние системы
Система уже имеет foundation layer, но он не доведён до конца.
Сильные стороны:
- единая палитра
- surface hierarchy
- `app-*` action и state primitives
- общая идея shared shell
Основные проблемы:
- разные радиусы у однотипных элементов
- дрейф карточек по визуальному весу
- разный dropdown affordance у похожих полей
- локальные ad-hoc решения в feature-страницах
Следствие:
- карточки и геометрию можно и нужно нормализовать системно
- формы нужно оценивать уже после этого, индивидуально по UX-паттернам

View File

@ -0,0 +1,150 @@
# Frontend Guide: Foundation
## Источники истины
Foundation layer уже существует и задаётся в этих файлах:
- `frontend/src/app/globals.css`
- `frontend/src/components/ui/Modal.tsx`
- `frontend/src/components/shared/SearchableSelect.tsx`
- `frontend/src/components/AppShell.tsx`
Новый UI должен опираться на них. Локальные utility-классы допустимы только как расширение, а не как новая параллельная система.
## Цветовые и surface-токены
Базовые переменные определены в `frontend/src/app/globals.css`.
Ключевые токены:
- `--background`
- `--foreground`
- `--muted-foreground`
- `--surface-primary`
- `--surface-elevated`
- `--surface-secondary`
- `--surface-tertiary`
- `--surface-overlay`
- `--border-subtle`
- `--border-strong`
- `--accent-primary`
- `--accent-primary-strong`
- `--accent-soft`
- `--accent-soft-strong`
- `--success-soft`
- `--danger-soft`
- `--warning-soft`
- `--shadow-card`
- `--shadow-elevated`
Правила:
- не вводить в новые экраны собственную “серую тему” через `bg-white`, `bg-gray-*`, `border-gray-*`
- не использовать локальный `focus:ring-sky-*`, если поведение уже покрывается `app-*`
- новый базовый цветовой токен сначала добавляется в `globals.css`, потом используется в компонентах
## Surface primitives
### `app-surface`
Использование:
- внешний самостоятельный блок
- основная карточка страницы
- primary content container
### `app-surface-elevated`
Использование:
- модалки
- overlay-панели
- визуально поднятые контейнеры
### `app-surface-muted`
Использование:
- вложенные секции
- grouped blocks
- вторичный внутренний слой
Правило вложенности:
- не строить цепочку `card inside card inside card`
- нормальный паттерн для большинства страниц: один внешний `app-surface` и один внутренний `app-surface-muted`
## Form primitives
### `app-input`
Использование:
- text inputs
- textareas
- date/time inputs
- field shells для compose-like строк, если это оправдано паттерном
### `app-select`
Использование:
- native `select`
- select-like triggers
Правило:
- если полю нужен поиск, мультивыбор, chips или сложный dropdown affordance, использовать shared select-компонент, а не локально стилизованный native `select`
## Action primitives
### `app-action-primary`
Использование:
- главное действие формы
- основной CTA секции
### `app-action-secondary`
Использование:
- вторичное действие
- отмена
- нейтральный CTA
### `app-action-ghost`
Использование:
- item actions внутри меню
- лёгкие встроенные действия
### `app-action-danger`
Использование:
- destructive actions
Правило:
- destructive state должен быть семантическим, а не декоративно тяжёлым
## Badge, menu, feedback, selected
Использовать существующие primitives:
- `app-badge`
- `app-badge-accent`
- `app-menu`
- `app-selected`
- `app-selected-soft`
- `app-feedback-success`
- `app-feedback-warning`
- `app-feedback-danger`
Правила:
- статус должен быть семантическим
- selected state не подменяется произвольным акцентным фоном
- helper и feedback-блоки должны опираться на существующие feedback classes

View File

@ -0,0 +1,120 @@
# Frontend Guide: Geometry
## Целевая шкала радиусов
Это нормативная target-scale для frontend UI:
- `rounded-lg` для controls
- `rounded-xl` для secondary cards и grouped blocks
- `rounded-2xl` для primary panels, page sections и modal shells
- `rounded-full` для pills, avatar, search capsules и похожих капсул
Эта шкала обязательна для новых экранов и перерабатываемых блоков.
## Controls
К controls относятся:
- input
- textarea
- select
- shared select trigger
- небольшие action buttons
- small menu buttons
Норма:
- `rounded-lg`
## Secondary cards
К secondary cards относятся:
- вложенные блоки внутри primary panel
- grouped filter blocks
- компактные detail sections
- utility cards среднего веса
Норма:
- `rounded-xl`
## Primary panels
К primary panels относятся:
- основные секции страниц
- большие feature containers
- modal shells
- крупные empty / detail / state containers
Норма:
- `rounded-2xl`
## Специальные случаи
Допустимо:
- `rounded-full` для capsules, chips, avatars
- редкое отдельное решение для hero/media container, если оно согласовано как special case
Недопустимо по умолчанию:
- новый “авторский” радиус без системной причины
## Legacy-геометрия
Считаются legacy:
- `rounded-md` у новых карточек и полей
- `rounded-[...]` без отдельного обоснования
Практический смысл:
- такое решение нельзя копировать в новые места
- при изменении существующего блока нужно оценить возможность перевода на базовую шкалу
## Таксономия карточек
### Primary container card
Признаки:
- основной контейнер feature-секции
- главный визуальный уровень страницы
Норма:
- `app-surface`
- `rounded-2xl`
### Secondary grouped block
Признаки:
- вложенный блок внутри primary panel
- объединяет несколько связанных элементов
Норма:
- `app-surface-muted`
- `rounded-xl`
### Inline utility / feedback block
Признаки:
- helper
- warning
- state summary
- selected or feedback fragment внутри карточки или формы
Норма:
- `app-selected`, `app-feedback-*` или `app-surface-muted`
- `rounded-lg` или `rounded-xl` в зависимости от веса блока
Правило:
- не превращать маленький helper в ещё одну primary-card конструкцию

View File

@ -0,0 +1,156 @@
# Frontend Guide: Patterns
## Формы
### Общие правила
Для базовых form elements использовать:
- `app-input`
- `app-select`
- shared select-компоненты из `frontend/src/components/shared/SearchableSelect.tsx`
Не допускается:
- смешивать на одном экране несколько разных focus/hover contracts без причины
- строить поле через случайные border/background utility-классы, если это уже покрыто foundation layer
### Формы оцениваются по UX-паттерну
Формы нельзя насильно привести к одному layout-паттерну. Они должны оцениваться по типу:
- CRUD form
- compose form
- filter form
- settings form
- dialog action form
Следствие:
- карточки можно нормализовать массово
- формы требуют ручного review по сценарию использования
Для `filter form` дополнительно:
- toolbar и фильтрующий блок лучше держать отдельным feature-компонентом, если page-level файл уже управляет списком, detail modal и compose flow одновременно
### Dropdown-like UX
Если поле:
- требует поиска
- поддерживает multi-select
- отображает выбранные элементы как chips
- ведёт себя как адресная строка compose-интерфейса
то приоритет у shared select-компонентов, а не у локально стилизованного native `select`.
Причина:
- dropdown affordance должен быть единым по приложению
- локальные стрелки, паддинги и раскрытия быстро создают вторую дизайн-систему
- filter-toolbar с сортировкой и раскрытием фильтров тоже не должен бесконечно разрастаться внутри page-level JSX
### Compose forms
Compose-паттерн должен восприниматься как рабочее пространство, а не как список независимых инпутов.
Особенно это важно для:
- заявлений
- сообщений
- писем
- согласований
Референс по духу:
- `frontend/src/components/requests/RequestComposeModal.tsx`
Базовый контракт:
- адресные строки сверху
- основное содержание формы в центре
- служебные поля ниже и визуально спокойнее
- действия формы собраны в одном предсказуемом footer
- page-level файл держит orchestration, а длинный compose workspace лучше выносить в feature component
## Модалки
### Базовый shell
Для обычных модалок использовать:
- `frontend/src/components/ui/Modal.tsx`
Новый modal pattern нельзя создавать без отдельной причины.
### Геометрия модалок
Target state:
- modal shell: `rounded-2xl`
Текущее переходное состояние:
- mobile может быть компактнее по геометрии, но без отдельного visual contract
### Responsive-логика
Разница между mobile и desktop должна достигаться через:
- responsive spacing
- responsive sizing
- изменение плотности
- sticky header/footer при необходимости
Не должно быть:
- отдельной mobile-версии той же модалки
- дублирования структуры ради платформы
## App Shell
`frontend/src/components/AppShell.tsx` задаёт верхнеуровневый контракт приложения:
- shell background
- header
- global search
- mobile drawers
- навигацию
Следствия:
- feature-страницы не должны переизобретать глобальный shell
- глобальные системные элементы должны оставаться в одном контракте
- capsule-геометрия допустима на уровне shell controls, например у глобального поиска
## Target-pattern registry
| Элемент | Рекомендуемый primitive / радиус | Допустимые исключения | Статус |
| --- | --- | --- | --- |
| Основная секция страницы | `app-surface rounded-2xl` | hero/media pattern по отдельному решению | `ok` |
| Внутренний grouped block | `app-surface-muted rounded-xl` | `rounded-lg`, если блок очень компактный | `needs refactor` |
| Modal shell | `app-surface-elevated rounded-2xl` | переходное mobile state допустимо временно | `ok` |
| Обычный input / textarea / select | `app-input` / `app-select` + `rounded-lg` | нет | `ok` |
| Searchable dropdown trigger | shared `SearchableSelect*` + `rounded-lg` | compose inline-row может быть крупнее по shell | `ok` |
| Compose address row | shared select pattern на базе `app-input` | `rounded-2xl` допустим для compose-shell строки | `ok` |
| Helper / alert / selected block | `app-selected`, `app-feedback-*`, `app-surface-muted` + `rounded-lg`/`rounded-xl` | зависит от веса блока | `needs refactor` |
| Pills / status chips / avatar | `rounded-full` | нет | `ok` |
| `rounded-md` у новых карточек и полей | не использовать | только legacy до отдельной переработки | `legacy` |
| `rounded-[...]` без обоснования | не использовать | только согласованные special cases | `legacy` |
## Do / Don’t
### Делать
- использовать `globals.css` как foundation layer
- опираться на `app-surface`, `app-input`, `app-select`, `app-action-*`
- выбирать радиус из базовой шкалы
- расширять существующий shared primitive, если паттерн уже есть
### Не делать
- не вводить новый радиус “под один экран”
- не стилизовать native `select` локальными хаками, если нужен shared dropdown pattern
- не смешивать old-gray-contract и `app-*` contract в одном новом блоке
- не дублировать shared interaction pattern в page-level JSX

View File

@ -0,0 +1,76 @@
# Frontend Guide: Migration
## Как трактовать legacy
Legacy не означает “удалить немедленно”. Это означает:
- решение нельзя копировать в новые места
- при касании блока нужно оценить перевод на target state
- если перевод пока невозможен, причина должна быть понятна и зафиксирована
## Что можно рефакторить массово
Без отдельного UX-исследования обычно можно:
- приводить card-like контейнеры к `rounded-xl` и `rounded-2xl`
- убирать `rounded-md` у карточек и стандартных полей
- сокращать `rounded-[...]` у обычных секций
- подтягивать простые inputs, buttons и surfaces к `app-*` primitives
## Что требует ручного review
Ручной review обязателен для:
- сложных форм
- compose workflows
- multi-step dialogs
- dropdown-полей со сложным поведением
- drawer-like и workspace-like интерфейсов
- экранов, где геометрия тесно завязана на сценарий использования
## Приоритет рефакторинга
Рекомендуемый порядок:
1. карточки и surface hierarchy
2. сокращение custom radii
3. выравнивание shared affordance
4. только после этого формы по отдельным UX-паттернам
## Ориентиры по крупным зонам
### `requests`
Использовать как ориентир по compose-настроению и декомпозиции насыщенного экрана.
Сейчас это reference для:
- идеи “форма как рабочее пространство”
- разрезания page-level orchestration и feature-level list/detail/controls/preview блоков
### `procurement`
Хороший кандидат для следующей волны нормализации крупных panel-контейнеров и detail cards.
### `equipment`
Подходит для рефакторинга secondary cards и form blocks, потому что там много повторяющейся геометрии.
### `settings`
Сильный ориентир по primary/secondary surface hierarchy, но там ещё встречаются кастомные радиусы, которые нужно сокращать.
### `documents`
Ключевая зона для аудита legacy-геометрии: заметно смешение `rounded-md`, `rounded-lg`, `rounded-xl` и локальных utility-решений.
## Правила локального override
Локальный override допустим только если:
- задача не покрывается существующим primitive
- решение действительно локально
- override не ломает base semantics цвета, surface или focus behavior
- у него есть понятное обоснование
Если override начинает повторяться, он должен быть поднят в shared layer.

View File

@ -0,0 +1,250 @@
# Frontend Guide: Component Inventory
Этот документ отвечает на вопрос: какие UI-примитивы и повторно используемые компоненты уже есть в коде и когда их нужно брать вместо создания нового решения.
## 1. Foundation primitives
Это первый выбор для любого нового экрана.
### `frontend/src/app/globals.css`
Назначение:
- цветовые токены
- surface hierarchy
- базовые form primitives
- action states
- feedback states
Использовать, когда:
- нужен новый экран на существующей дизайн-системе
- нужно собрать карточку, поле, кнопку или feedback-блок без изобретения новой визуальной базы
Не использовать как:
- место для локального feature-хака без повторного применения
### `frontend/src/components/AppShell.tsx`
Что даёт:
- shell background
- header
- global search
- mobile navigation drawers
- `PageHeader`
Использовать, когда:
- нужен normal page внутри основного приложения
Не использовать, когда:
- экран является отдельным auth/legal flow и у него другой layout-contract
### `frontend/src/components/ui/Modal.tsx`
Что даёт:
- единый modal shell
- focus trap
- close handling
- header/footer contract
- responsive sizing
Использовать, когда:
- нужен обычный modal dialog
- нет отдельного сценария drawer/workspace/fullscreen flow
Не использовать как:
- основание для новой параллельной modal-системы
### `frontend/src/components/shared/SearchableSelect.tsx`
Компоненты:
- `SearchableSelectSingle`
- `SearchableSelectMulti`
Использовать, когда:
- нужен поиск по списку
- нужен multi-select
- нужно chip-представление выбранных значений
- нужен compose-like address field
Не использовать native `select`, если:
- начинаются локальные доработки стрелки
- нужен поиск
- нужен выбор нескольких пользователей
## 2. Shell-level и системные компоненты
### `NotificationCenter`
Роль:
- системный блок уведомлений
Когда использовать:
- в shell или system-level surface
- не как основу для произвольных выпадающих списков
### `MobileLeftDrawer` и `MobileCalendarDrawer`
Роль:
- мобильные системные drawer-паттерны
Когда использовать:
- для app-level mobile navigation / side panels
Не использовать:
- как shortcut для каждой произвольной feature-модалки
## 3. Feature-shared компоненты
Эти компоненты полезны как референс и иногда как основа для похожих сценариев, но они уже ближе к домену.
### Calendar
Файлы:
- `CalendarModal.tsx`
- `EventModal.tsx`
- `CalendarParticipantsModal.tsx`
- `CalendarSubscriptionsModal.tsx`
- `ViewDayEventsModal.tsx`
- `ViewEventDetailsModal.tsx`
- `calendar/CalendarSidebar.tsx`
- `calendar/CalendarCard.tsx`
Использовать как референс для:
- event forms
- side panels
- selection-heavy dialog flows
С оговоркой:
- calendar UI ещё не является эталоном всей системы и содержит legacy-решения
### Documents
Файлы:
- `DocumentDetailModal.tsx`
- `DocumentUploadForm.tsx`
- `DocumentPreview.tsx`
- `DocumentMetadataEditor.tsx`
- `DocumentComments.tsx`
Использовать как референс для:
- document-heavy UI
- detail panels
- upload flows
- metadata forms
С оговоркой:
- documents содержит заметный пласт legacy-геометрии и нуждается в дальнейшем выравнивании
### Requests
Файлы:
- `requests/RequestAttachmentPreviewModal.tsx`
- `requests/RequestComposeModal.tsx`
- `requests/RequestDetailModal.tsx`
- `requests/RequestListControls.tsx`
- `requests/RequestListItem.tsx`
- `requests/RequestUserBadge.tsx`
- `requests/SwipeApprovalMode.tsx`
- `app/requests/page.tsx`
- `hooks/useRequestsPage.ts`
Использовать как референс для:
- compose-like form pattern
- detail modal с participant-heavy content
- page-level toolbar/filter block, который вынесен из страницы в feature component
- dense list row с actions/comments/expandable secondary content
- 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
### Users / profile-like screens
Файлы:
- `users/ProfileSections.tsx`
- `users/EditUserProfileModal.tsx`
- `users/EmployeeActionModal.tsx`
- `users/EmployeeActionsTimeline.tsx`
Использовать как референс для:
- profile hero
- profile sections
- info cards
- action timeline
### Messages
Файлы:
- `messages/MessageComposer.tsx`
- `messages/ChatMessageItem.tsx`
- `messages/ChatDialogHeader.tsx`
- `ScrollableMessageList.tsx`
Использовать как референс для:
- dense interactive lists
- composer area
- dialog-like chat surface
С оговоркой:
- chat решает очень специфический UX, поэтому не должен автоматически становиться шаблоном для остальных модулей
## 4. Что считать shared, а что нет
Компонент нужно считать shared, если:
- он решает повторяемую визуальную механику
- у него нет жёсткой доменной привязки
- он пригоден минимум для двух feature-зон
Компонент не нужно тащить в shared, если:
- он глубоко привязан к одному домену
- у него много domain-specific props и состояний
- он служит хорошим референсом, но плохим универсальным primitive
## 5. Быстрый выбор перед новой разработкой
Если нужен:
- page shell: `AppShell`, `PageHeader`
- modal: `Modal`
- searchable dropdown: `SearchableSelect*`
- compose address line: `SearchableSelectMulti` с compose-like layout
- primary/secondary surface: `app-surface` / `app-surface-muted`
- feedback block: `app-selected`, `app-feedback-*`
Если после этого всё ещё хочется создать новый primitive, сначала проверить:
- нет ли уже похожего решения в `components`
- нельзя ли расширить существующий shared-компонент
- не пытаемся ли мы решить локальную проблему новым глобальным паттерном

View File

@ -0,0 +1,147 @@
# Frontend Guide: Developer Workflow
Этот документ отвечает на вопрос: как practically разрабатывать новый UI в этом проекте.
## 1. Порядок работы над новой UI-задачей
Рекомендуемый порядок:
1. понять, это новый паттерн или существующий
2. найти ближайшие shared primitives
3. определить уровень поверхности и геометрии
4. собрать layout из существующих слоёв
5. вынести тяжёлые feature-блоки из page-level файла, если экран уже растёт за счёт list row / detail modal / compose workspace
6. только потом добавлять локальные override
Неправильный порядок:
1. сразу писать JSX с локальными utility-классами
2. потом пытаться “подогнать под проект”
## 2. Как делать новый экран
Перед началом нового экрана:
- определить, это обычная app-page или отдельный auth/legal flow
- понять, нужен ли `AppShell`
- определить основной primary panel
- определить, сколько secondary blocks реально нужно
Практический шаблон:
1. взять `AppShell`, если это обычная страница приложения
2. использовать `PageHeader`, если нужен стандартный page intro
3. построить крупные контейнеры как `app-surface rounded-2xl`
4. вложенные секции делать через `app-surface-muted rounded-xl`
5. поля формы строить на `app-input` / `app-select`
## 3. Как делать новую карточку
Перед созданием карточки спросить:
- это primary container или secondary grouped block?
- карточка реально нужна, или это просто внутренний helper block?
- не создаём ли мы третью глубину вложенности без необходимости?
Норма:
- primary card: `app-surface rounded-2xl`
- secondary card: `app-surface-muted rounded-xl`
- маленький helper: `rounded-lg` или `rounded-xl`, но не новая “мини-основная карточка”
## 4. Как делать новую форму
Сначала определить тип формы:
- CRUD
- compose
- filters
- settings
- dialog action
После этого:
- выбрать подходящий form contract
- использовать общие primitives
- решить, какие поля главные, а какие вторичные
- если compose-форма превращается в workspace, вынести её из page-level JSX в feature component
Важно:
- не все формы должны выглядеть одинаково
- но все формы должны жить в одной визуальной системе
- page-level файл не должен оставаться местом для длинной compose-модалки, если она уже стала отдельным рабочим сценарием
То же правило для list/detail flow:
- если у страницы появились тяжёлые expandable rows, detail modal или participant-heavy blocks, это уже кандидат на feature components, а не на дальнейшее разрастание page-level JSX
- то же касается toolbar/filter block и attachment preview, если они уже стали самостоятельными зонами сценария
## 5. Как делать dropdown-поле
Порядок решения:
1. если это простой одиночный выбор без поиска, можно использовать `app-select`
2. если нужен поиск, chips, multi-select или compose-поведение, брать `SearchableSelect*`
3. если хочется “чуть-чуть допилить стрелочку”, сначала проверить, не означает ли это, что нужен shared pattern, а не local fix
Красный флаг:
- локальная стилизация native `select`, чтобы он выглядел как shared dropdown, почти всегда означает неправильный выбор primitive
## 6. Как делать модалку
Если нужен обычный dialog:
- использовать `Modal`
Перед реализацией определить:
- это modal dialog, drawer или workspace?
- нужен ли sticky footer?
- нужна ли длинная scrollable content area?
Нормальный modal workflow:
1. взять `Modal`
2. определить размер
3. собрать shell на `app-surface-elevated`
4. выстроить content hierarchy
5. проверить mobile-скролл и footer actions
## 7. Когда выносить в shared
Выносить в shared, если:
- паттерн повторится минимум ещё раз
- это не доменная логика, а UI-механика
- локальный код начинает превращаться в новый mini-framework внутри страницы
Не выносить рано, если:
- решение ещё сырое
- паттерн пока узкоспецифичный
- компонент завязан на один домен и вряд ли станет общим
## 8. Когда можно делать локальный override
Локальный override допустим, если:
- нет подходящего primitive
- решение реально локально
- не ломается общий visual contract
- override не рождает новый незафиксированный паттерн
Если override начинает повторяться, это уже shared problem, а не local exception.
## 9. Что проверять до завершения работы
Перед тем как считать UI-задачу завершённой, проверить:
- поверхность и радиусы соответствуют guide
- нет лишнего custom radius
- поля используют `app-*` primitives
- dropdown affordance не локально придуман
- mobile layout выглядит естественно
- действия формы и карточки читаются по визуальному весу
- role-based фронтовые эвристики не подменяют API contract там, где источник истины должен приходить с backend

View File

@ -0,0 +1,64 @@
# Frontend Guide: UI Review Checklist
Этот документ нужен для self-check перед коммитом и для code review.
## 1. Geometry
- Используется ли базовая шкала радиусов?
- Нет ли нового `rounded-[...]` без отдельного обоснования?
- Не используется ли `rounded-md` там, где нужен normal control/card contract?
- Совпадает ли визуальный вес элементов одного типа?
## 2. Surfaces
- Понятно ли, где primary panel, а где secondary grouped block?
- Нет ли лишней вложенности `card inside card inside card`?
- Используются ли `app-surface`, `app-surface-elevated`, `app-surface-muted` по назначению?
## 3. Forms
- Форма собрана на `app-input` / `app-select` / shared select-паттернах?
- Понятно ли, какие поля главные, а какие вторичные?
- Не перегружена ли форма визуально?
- Согласован ли паттерн формы с её сценарием: CRUD, compose, filters, settings, dialog?
## 4. Dropdowns
- Если нужен поиск или multi-select, используется ли shared select?
- Не стилизован ли native `select` так, как будто это другой компонент?
- Совпадает ли affordance dropdown-поля с остальным приложением?
## 5. Actions
- Есть ли один очевидный primary action?
- Не спорят ли secondary и danger actions по визуальному весу с primary?
- Семантика destructive actions понятна?
## 6. Responsive
- Экран или модалка читаются на mobile без отдельной layout-ветки?
- Header/footer остаются удобными при длинном контенте?
- Нет ли случайного horizontal overflow?
## 7. API contract и frontend logic
- Не вычисляет ли фронтенд права доступа через устаревшие role-based эвристики?
- Там, где backend уже отдаёт семантический флаг, используется ли он напрямую?
- UI корректно скрывает действия, которые недоступны по business contract?
Особенно важно для:
- requests
- comments
- decision actions
- admin vs participant-only flows
## 8. Legacy
- Не копируется ли legacy-решение в новый код?
- Если legacy оставлен, понятна ли причина?
- Можно ли было безопасно привести участок к target state уже в этой задаче?
## 9. Decision
Если по checklist есть много “нет”, задача не считается просто “визуально нормальной”. Она должна либо быть доделана, либо иметь явный зафиксированный компромисс.

View File

@ -0,0 +1,46 @@
# Frontend Guide: Audit Map
Этот документ нужен как стартовая карта рефакторинга. Он не заменяет подробный аудит, но фиксирует, какие крупные зоны уже ближе к target state, а какие ещё содержат заметный объём legacy.
## Статусы
- `ok` — зона уже достаточно согласована с guide
- `mixed` — есть сильные части, но заметно смешение old/new patterns
- `needs refactor` — зона должна попасть в один из следующих refactor pass
- `legacy-heavy` — много технического долга и старых паттернов
## Карта зон
| Зона | Статус | Что уже хорошо | Основные проблемы | Рекомендуемый следующий шаг |
| --- | --- | --- | --- | --- |
| `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 |
| `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 |
| `procurement` | `mixed` | крупные панели и часть detail blocks уже читаются неплохо | остаются старые form fragments и неоднородные локальные поля | нормализовать page panels и supplier/request forms |
| `calendar` | `legacy-heavy` | богатая функциональность, много reusable доменных компонентов | historical layering, legacy-термины и неоднородные модалки | отдельный audit до системного refactor |
| `messages` | `mixed` | зрелая interaction model, сильный composer и message item | очень доменно-специфичный UX, не всё переносимо как общий стандарт | использовать выборочно как reference, без механического копирования в другие зоны |
| `users/profile` | `mixed` | хорошие profile sections и hero-like patterns | часть экранов ещё может дрейфовать по локальным решениям | держать как reference для profile contract и точечно упрощать |
| `documents/dashboard` | `legacy-heavy` | есть готовые data blocks | заметны старые rounded patterns и мелкие utility-решения | включить в documents refactor pass |
## Порядок следующего рефакторинга
Рациональный порядок:
1. `settings`
2. `documents`
3. `equipment`
4. `procurement`
5. отдельный audit по `calendar`
`requests` лучше использовать как референс для compose-настроения и выделения feature-level workspace/list-detail/controls блоков уже сейчас, а не как первую цель глобального переписывания.
## Как использовать эту карту
Перед началом крупной UI-задачи:
- посмотреть статус зоны
- понять, это target-like area или legacy area
- решить, задача локальная или это хороший момент для mini-refactor
Если зона помечена как `needs refactor` или `legacy-heavy`, новый код внутри неё особенно не должен копировать старые решения “как есть”.

View File

@ -0,0 +1,104 @@
# Frontend UI Guide
Статус: нормативная карта frontend UI-системы проекта.
Этот раздел фиксирует целевое состояние интерфейса и служит точкой входа для всех решений по стилям, геометрии, карточкам, формам, модалкам и shared UI primitives. Это не одна заметка, а карта правил, по которой нужно проектировать новые экраны и проводить рефакторинг старых.
## Как читать этот раздел
Порядок чтения:
1. [01-OVERVIEW.md](01-OVERVIEW.md)
Что это за guide, где проходят границы и как устроен frontend по слоям.
2. [02-FOUNDATION.md](02-FOUNDATION.md)
Базовые токены, поверхности, action/form primitives и источники истины.
3. [03-GEOMETRY.md](03-GEOMETRY.md)
Целевая шкала радиусов, таксономия карточек и допустимые исключения.
4. [04-PATTERNS.md](04-PATTERNS.md)
Формы, dropdown-like UX, модалки, App Shell и target-pattern registry.
5. [05-MIGRATION.md](05-MIGRATION.md)
Что считать legacy, как двигать рефакторинг и по каким зонам идти.
6. [06-COMPONENT-INVENTORY.md](06-COMPONENT-INVENTORY.md)
Какие shared и feature-shared компоненты уже есть и когда какой использовать.
7. [07-DEVELOPER-WORKFLOW.md](07-DEVELOPER-WORKFLOW.md)
Как проектировать новый экран, форму, карточку, модалку и когда выносить код в shared.
8. [08-UI-REVIEW-CHECKLIST.md](08-UI-REVIEW-CHECKLIST.md)
Короткий checklist для PR review и самопроверки перед merge.
9. [09-FRONTEND-AUDIT-MAP.md](09-FRONTEND-AUDIT-MAP.md)
Карта текущих зон frontend: что уже близко к standard, а что требует рефакторинга.
## Для чего этот guide
Guide обязателен для:
- новых экранов в `frontend/src/app`
- новых shared-компонентов в `frontend/src/components`
- переработки существующих карточек, форм, модалок и dropdown-паттернов
- code review, где нужно определить, является ли локальное UI-решение допустимым
Guide не предназначен для:
- описания API-контрактов
- описания state management конкретной фичи
- feature-specific UX-спеков
## Главная проблема системы
Сейчас frontend уже достаточно зрелый, чтобы его нормализовать системно.
Что уже в хорошем состоянии:
- палитра
- surface hierarchy
- акцентные состояния
- базовые `app-*` primitives
Что расползлось:
- геометрия
- шкала радиусов
- affordance dropdown-полей
- повторяющиеся card patterns
- разные form contracts для похожих сценариев
Именно поэтому этот раздел фиксирует target state заранее, даже если кодовая база ещё не полностью к нему приведена.
## Источники истины
Ключевые frontend primitives, на которые опирается весь guide:
- `frontend/src/app/globals.css`
- `frontend/src/components/ui/Modal.tsx`
- `frontend/src/components/shared/SearchableSelect.tsx`
- `frontend/src/components/AppShell.tsx`
- `frontend/src/components/requests/RequestComposeModal.tsx` как текущий reference для compose-like participant form
## Быстрый контракт
Если нужно принять быстрое решение без чтения всего guide:
- controls: `rounded-lg`
- secondary cards: `rounded-xl`
- primary panels и modal shells: `rounded-2xl`
- pills, avatar, search capsules: `rounded-full`
- `rounded-md` и `rounded-[...]`: legacy или редкое исключение с отдельным обоснованием
- dropdown UX с поиском, multi-select или chip-представлением должен идти через shared select-паттерн, а не через локальный ad-hoc `select`
## Навигация по рефакторингу
С этого раздела должен начинаться будущий глобальный refactor pass:
1. нормализация карточек
2. сокращение custom radii
3. выравнивание shared affordance
4. только потом индивидуальный разбор форм по паттернам
## Что уже покрыто
Этот раздел теперь выполняет две функции:
- нормативная база по visual contract
- прикладной handbook для ежедневной разработки и review
Если вопрос про то, "как должно быть", смотреть документы `01-05`.
Если вопрос про то, "как это делать в коде", смотреть документы `06-09`.

View File

@ -2,25 +2,18 @@
/* eslint-disable react-hooks/refs -- All h.* values come from useState, not useRef. React compiler false positive. */
import { AppShell } from "../../components/AppShell";
import { Modal } from "@/components/ui";
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 { useUser } from "@/contexts/UserContext";
import { canManageRequests, canProcessRequests } from "@/lib/permissions";
import { Suspense, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { User } from "@/types/api";
import { ArrowUpDown, Ban, ChevronDown, ChevronRight, FileSignature, Filter, MessageSquare, Paperclip, Pencil, Plus, Search, ThumbsDown, ThumbsUp, Trash2, X, Zap } from "lucide-react";
import { FileSignature, Zap } from "lucide-react";
import dynamic from "next/dynamic";
import { SearchableSelectMulti } from "@/components/shared/SearchableSelect";
import { formatDate, formatDateTime, displayUserName, userProfileLink } from "@/lib/shared";
import {
useRequestsPage,
statusMeta,
defaultStatusMeta,
requestTypeLabels,
orderingOptions,
} from "@/hooks/useRequestsPage";
import { useRequestsPage } from "@/hooks/useRequestsPage";
const SwipeApprovalMode = dynamic(() => import("@/components/requests/SwipeApprovalMode"), { ssr: false });
@ -48,17 +41,21 @@ function RequestsPageContent() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const canProcess = canProcessRequests(user);
const canManage = canManageRequests(user);
const auth = user?.auth;
const h = useRequestsPage(user?.id);
const fileInputRef = useRef<HTMLInputElement>(null);
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());
@ -71,27 +68,6 @@ function RequestsPageContent() {
clearRequestParam();
};
const renderUserBadge = (person: User, large = false) => {
const personLink = userProfileLink(person, user?.id);
const personName = displayUserName(person);
const chip = (
<>
{person.avatar ? (
<img src={person.avatar} alt={personName} className={`app-avatar-frame ${large ? "h-7 w-7" : "h-6 w-6"} shrink-0 rounded-full object-cover`} />
) : (
<span className={`app-avatar-fallback ${large ? "h-7 w-7 text-xs" : "h-6 w-6 text-[11px]"} flex shrink-0 items-center justify-center rounded-full font-semibold`}>
{(person.first_name?.[0] || person.last_name?.[0] || "?").toUpperCase()}
</span>
)}
<span className="break-words">{personName}</span>
</>
);
const cls = `app-badge inline-flex max-w-full items-center gap-2 rounded-full ${large ? "px-3 py-1.5 text-sm" : "px-2.5 py-1 text-xs"} font-medium`;
return personLink
? <Link href={personLink} className={`${cls} hover:bg-[var(--surface-tertiary)]`}>{chip}</Link>
: <span className={cls}>{chip}</span>;
};
useEffect(() => {
if (!linkedRequestId) {
openedLinkedRequestIdRef.current = null;
@ -99,7 +75,7 @@ function RequestsPageContent() {
return;
}
if (h.detailsRequest?.id === linkedRequestId) {
if (detailsRequestId === linkedRequestId) {
openedLinkedRequestIdRef.current = linkedRequestId;
loadingLinkedRequestIdRef.current = null;
return;
@ -109,11 +85,11 @@ function RequestsPageContent() {
return;
}
const existing = h.requests.find((item) => item.id === linkedRequestId);
const existing = requestList.find((item) => item.id === linkedRequestId);
if (existing) {
openedLinkedRequestIdRef.current = linkedRequestId;
loadingLinkedRequestIdRef.current = null;
h.setDetailsRequest(existing);
setDetailsRequest(existing);
return;
}
@ -130,7 +106,7 @@ function RequestsPageContent() {
if (!cancelled && openedLinkedRequestIdRef.current !== linkedRequestId) {
openedLinkedRequestIdRef.current = linkedRequestId;
loadingLinkedRequestIdRef.current = null;
h.setDetailsRequest(request);
setDetailsRequest(request);
}
})
.catch((error) => {
@ -141,7 +117,7 @@ function RequestsPageContent() {
return () => {
cancelled = true;
};
}, [h.detailsRequest?.id, h.requests, h.setDetailsRequest, linkedRequestId]);
}, [detailsRequestId, linkedRequestId, requestList, setDetailsRequest]);
useEffect(() => {
if (requestMenuOpenId === null) return;
@ -178,227 +154,90 @@ 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 && (canManage || canProcess) ? (
{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={h.requests} onApprove={h.handleApprove} onReject={h.handleReject} onClose={() => h.setSwipeMode(false)} />
<SwipeApprovalMode requests={pendingDecisionRequests} onApprove={h.handleApprove} onReject={h.handleReject} onClose={() => h.setSwipeMode(false)} />
</div>
) : (
<>
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Заявления</p>
{(canManage || canProcess) && (
<button type="button" onClick={() => h.setSwipeMode(true)} 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>
)}
</div>
<button type="button" onClick={h.openCreate} 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>
{h.actionError && <p className="app-feedback-danger mb-3 rounded-lg px-3 py-2 text-sm">{h.actionError}</p>}
{h.actionSuccess && <p className="app-feedback-success mb-3 rounded-lg px-3 py-2 text-sm">{h.actionSuccess}</p>}
<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={h.search} onChange={(e) => h.setSearch(e.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={() => h.setFiltersOpen((v) => !v)} className={`relative inline-flex items-center justify-center rounded-lg p-2.5 transition ${h.filtersOpen ? "app-selected app-accent-text" : "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"}`}>
<Filter size={16} />
{[h.view, h.typeFilter, h.statusFilter, h.employeeFilter, h.createdFromFilter, h.createdToFilter, h.periodFromFilter, h.periodToFilter].filter(Boolean).length > 0 && (
<span className="app-counter absolute -right-1 -top-1 flex h-4 min-w-4 px-1 text-[10px] font-bold">{[h.view, h.typeFilter, h.statusFilter, h.employeeFilter, h.createdFromFilter, h.createdToFilter, h.periodFromFilter, h.periodToFilter].filter(Boolean).length}</span>
)}
</button>
<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={h.ordering} onChange={(e) => h.setOrdering(e.target.value)} className="app-select w-full appearance-none rounded-lg py-2.5 pl-9 pr-8 text-xs font-medium" aria-label="Сортировка">
{orderingOptions.map((o) => <option key={o.value} value={o.value}>{o.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>
{h.filtersOpen && (
<div className="app-surface-muted mb-3 flex flex-col gap-2 rounded-xl p-3">
<select value={h.view} onChange={(e) => h.setView(e.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={h.typeFilter} onChange={(e) => h.setTypeFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm"><option value="">Тип заявления</option>{Object.entries(requestTypeLabels).map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select>
<select value={h.statusFilter} onChange={(e) => h.setStatusFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm"><option value="">Статус заявления</option>{Object.entries(statusMeta).map(([v, m]) => <option key={v} value={v}>{m.label}</option>)}</select>
<select value={h.employeeFilter} onChange={(e) => h.setEmployeeFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm"><option value="">Все сотрудники</option>{h.employees.map((e) => <option key={e.id} value={e.id}>{displayUserName(e)}</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={h.createdFromFilter} onChange={(e) => h.setCreatedFromFilter(e.target.value)} className="app-input flex-1 rounded-lg px-3 py-2 text-sm" /><input type="date" value={h.createdToFilter} onChange={(e) => h.setCreatedToFilter(e.target.value)} className="app-input flex-1 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="flex gap-2"><input type="date" value={h.periodFromFilter} onChange={(e) => h.setPeriodFromFilter(e.target.value)} className="app-input flex-1 rounded-lg px-3 py-2 text-sm" /><input type="date" value={h.periodToFilter} onChange={(e) => h.setPeriodToFilter(e.target.value)} className="app-input flex-1 rounded-lg px-3 py-2 text-sm" /></div></div>
{[h.view, h.typeFilter, h.statusFilter, h.employeeFilter, h.createdFromFilter, h.createdToFilter, h.periodFromFilter, h.periodToFilter].some(Boolean) && (
<button type="button" onClick={() => { h.setView(""); h.setTypeFilter(""); h.setStatusFilter(""); h.setEmployeeFilter(""); h.setCreatedFromFilter(""); h.setCreatedToFilter(""); h.setPeriodFromFilter(""); h.setPeriodToFilter(""); }} className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium transition">Очистить фильтры</button>
)}
</div>
)}
<RequestListControls
actionError={h.actionError}
actionSuccess={h.actionSuccess}
createdFromFilter={h.createdFromFilter}
createdToFilter={h.createdToFilter}
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("");
}}
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={pendingDecisionRequests.length}
periodFromFilter={h.periodFromFilter}
periodToFilter={h.periodToFilter}
search={h.search}
statusFilter={h.statusFilter}
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) => {
const requestAuthor = item.employee || item.created_by;
const authorName = displayUserName(requestAuthor);
const sk = String(item.status || "").toLowerCase();
const tk = String(item.type || item.request_type || "").toLowerCase();
const st = statusMeta[sk] ?? defaultStatusMeta;
const al = userProfileLink(requestAuthor as User, user?.id);
const tl = requestTypeLabels[tk] || String(item.type || item.request_type || "Другое");
const tt = item.display_title || item.title || "Без заголовка";
const canPr = Boolean(sk === "pending" && requestAuthor?.id && user?.id && requestAuthor.id !== user.id && (canManage || (canProcess && item.is_recipient)));
const isAuth = Boolean(requestAuthor?.id && user?.id && requestAuthor.id === user.id);
const canCancel = Boolean(isAuth && !h.isFinal(sk));
const canEdit = Boolean(isAuth && !h.isFinal(sk));
const canDelete = Boolean((isAuth && !h.isFinal(sk)) || canManage);
const hasSecondaryActions = canCancel || canEdit || canDelete;
const rowOpen = Boolean(h.expandedRows[item.id]);
const comments = h.commentsMap[item.id] || [];
const commentsOpen = Boolean(h.expandedComments[item.id]);
const deptLabels = (item.departments || []).map((id) => h.departmentNameMap.get(Number(id)) || `Отдел #${id}`).join(", ");
const recs = item.recipients || [];
const ccs = item.cc_users || [];
const summary = item.comment || item.description;
return (
<article key={item.id} className={`app-surface-muted rounded-xl transition hover:border-[var(--border-strong)] ${requestMenuOpenId === item.id ? "relative z-20 overflow-visible" : "overflow-hidden"}`}>
<div className="p-4">
<div className="flex items-start gap-3">
<div className="flex shrink-0 flex-col items-center gap-3 pt-0.5">
<button type="button" onClick={() => h.toggleRow(item.id)} className="app-action-secondary inline-flex h-8 w-8 items-center justify-center rounded-lg transition"><ChevronDown size={15} className={`transition ${rowOpen ? "rotate-180" : ""}`} /></button>
<button type="button" title={`Комментарии (${item.comments_count ?? comments.length})`} onClick={() => h.toggleComments(item.id)} className="app-action-secondary relative inline-flex h-8 w-8 items-center justify-center rounded-lg">
<MessageSquare size={15} />
{(item.comments_count ?? comments.length) > 0 && <span className="app-counter absolute -right-1.5 -top-1.5 flex h-4 min-w-4 px-1 text-[10px] font-bold">{item.comments_count ?? comments.length}</span>}
</button>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-center gap-2">
{al ? (
<Link href={al} className="group flex min-w-0 items-center gap-2">
{requestAuthor?.avatar ? <img src={requestAuthor.avatar} alt={authorName} className="app-avatar-frame h-8 w-8 shrink-0 rounded-full object-cover" /> : <span className="app-avatar-fallback flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold">{(requestAuthor?.first_name?.[0] || requestAuthor?.last_name?.[0] || "?").toUpperCase()}</span>}
<span className="truncate text-sm font-medium text-[var(--foreground)] group-hover:text-[var(--accent-primary-strong)]">{authorName}</span>
</Link>
) : (
<div className="flex min-w-0 items-center gap-2"><span className="app-badge flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold">?</span><span className="truncate text-sm font-medium text-[var(--foreground)]">{authorName}</span></div>
)}
</div>
<button type="button" onClick={() => h.setDetailsRequest(item)} className="block w-full text-left">
<h3 className={`${rowOpen ? "app-text-wrap line-clamp-3" : "truncate"} text-sm font-semibold text-[var(--foreground)] transition hover:text-[var(--accent-primary-strong)]`}><span className="app-text-muted">{tl}:</span> <span className="text-[var(--foreground)]">{tt}</span></h3>
</button>
<div className="app-text-muted mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs"><span>Период: {item.date_from ? formatDate(item.date_from) : "—"}{item.date_to ? ` — ${formatDate(item.date_to)}` : ""}</span></div>
</div>
<div className="shrink-0">
<div
ref={requestMenuOpenId === item.id ? requestMenuRef : null}
className="flex items-center justify-end gap-2"
>
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs ring-1 ${st.className}`}>{st.label}</span>
{hasSecondaryActions ? (
<div className="relative">
<button
type="button"
onClick={() => setRequestMenuOpenId((prev) => (prev === item.id ? null : item.id))}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
title="Действия с заявлением"
aria-label="Действия с заявлением"
aria-expanded={requestMenuOpenId === item.id}
aria-haspopup="menu"
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${requestMenuOpenId === item.id ? "rotate-90" : ""}`}
/>
</button>
{requestMenuOpenId === item.id ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-44 rounded-xl py-1.5">
{canCancel ? (
<button
type="button"
disabled={h.busyKey === `cancel-${item.id}`}
onClick={() => {
setRequestMenuOpenId(null);
void h.handleCancel(item.id);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:opacity-50"
>
<Ban size={14} />
Отменить
</button>
) : null}
{canEdit ? (
<button
type="button"
onClick={() => {
setRequestMenuOpenId(null);
h.openEdit(item);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} />
Редактировать
</button>
) : null}
{canDelete ? (
<button
type="button"
disabled={h.busyKey === `delete-${item.id}`}
onClick={() => {
setRequestMenuOpenId(null);
void h.handleDelete(item.id);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)] disabled:opacity-50"
>
<Trash2 size={14} />
Удалить
</button>
) : null}
</div>
) : null}
</div>
) : null}
</div>
</div>
</div>
{summary && <p className={`${rowOpen ? "app-text-wrap line-clamp-10" : "app-text-wrap line-clamp-3"} mt-3 text-sm text-[var(--foreground)]`}>{summary}</p>}
{canPr && <div className={`${summary ? "mt-3" : "mt-2"} flex flex-wrap items-center gap-1.5`}><span className="ml-auto inline-flex items-center gap-2"><button type="button" title="Одобрить" onClick={() => h.handleApprove(item.id)} disabled={h.busyKey === `approve-${item.id}`} className="app-feedback-success inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"><ThumbsUp size={18} /></button><button type="button" title="Отклонить" onClick={() => h.handleReject(item.id)} disabled={h.busyKey === `reject-${item.id}`} className="app-action-danger inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"><ThumbsDown size={18} /></button></span></div>}
</div>
</div>
{(rowOpen || commentsOpen) && (
<div className="app-surface-elevated mt-4 rounded-xl p-4">
{rowOpen && (
<div className="space-y-3 text-xs">
<div className="grid grid-cols-1 gap-x-4 gap-y-2 sm:grid-cols-2">
<div><span className="app-text-muted">Решающий:</span> {(() => { const a = item.approver || item.assigned_to; if (!a) return <span className="app-text-muted">—</span>; const alink = userProfileLink(a as User, user?.id); const aname = displayUserName(a); return alink ? <Link href={alink} className="app-link-accent font-medium">{aname}</Link> : <span className="font-medium text-[var(--foreground)]">{aname}</span>; })()}</div>
<div><span className="app-text-muted">Создано:</span> <span className="font-medium text-[var(--foreground)]">{formatDate(item.created_at) || "—"}</span></div>
<div><span className="app-text-muted">Обновлено:</span> <span className="font-medium text-[var(--foreground)]">{formatDate(item.updated_at) || "—"}</span></div>
{deptLabels && <div className="sm:col-span-2"><span className="app-text-muted">Отделы:</span> <span className="font-medium text-[var(--foreground)]">{deptLabels}</span></div>}
</div>
<div className="space-y-2">
<div className="flex flex-wrap items-start gap-2"><span className="app-text-muted pt-1">Получатели:</span><div className="flex min-w-0 flex-1 flex-wrap gap-1.5">{recs.slice(0,2).map((r) => <div key={r.id}>{renderUserBadge(r)}</div>)}{recs.length === 0 && <span className="app-text-muted pt-1">{item.recipient_count ?? 0}</span>}{recs.length > 2 && <span className="app-badge px-2 py-1 text-xs font-medium">+{recs.length - 2}</span>}</div></div>
<div className="flex flex-wrap items-start gap-2"><span className="app-text-muted pt-1">В копии:</span><div className="flex min-w-0 flex-1 flex-wrap gap-1.5">{ccs.slice(0,2).map((c) => <div key={c.id}>{renderUserBadge(c)}</div>)}{ccs.length === 0 && <span className="app-text-muted pt-1">—</span>}{ccs.length > 2 && <span className="app-badge px-2 py-1 text-xs font-medium">+{ccs.length - 2}</span>}</div></div>
{(item.attachment || item.attachment_url) && <div className="flex min-w-0 items-center gap-1.5"><button type="button" onClick={() => { const url = item.attachment_url || item.attachment || ""; h.setAttachmentPreview({ url, name: decodeURIComponent(url.split("/").pop() || "Вложение") }); }} className="app-badge app-badge-accent inline-flex min-w-0 max-w-full items-center gap-1.5 px-2.5 py-1 text-xs font-medium"><Paperclip size={13} className="shrink-0" /><span className="truncate">{decodeURIComponent((item.attachment_url || item.attachment || "").split("/").pop() || "Вложение")}</span></button></div>}
</div>
</div>
)}
{commentsOpen && (
<div className={rowOpen ? "app-surface mt-3 rounded-xl p-3" : "app-surface rounded-xl p-3"}>
<div className="space-y-2">{comments.length === 0 ? <p className="app-text-muted text-xs">Комментариев пока нет</p> : comments.map((c) => (<div key={c.id} className="app-surface-muted rounded-lg px-3 py-2 text-xs text-[var(--foreground)]"><div className="mb-1 flex items-center justify-between gap-2"><span className="font-medium">{displayUserName(c.author)}</span><div className="flex items-center gap-2"><span className="app-text-muted">{formatDate(c.created_at)}</span>{Boolean(c.author?.id && (user?.id === c.author.id || auth?.is_staff || auth?.is_superuser)) && <button type="button" onClick={() => h.handleDeleteComment(item.id, c.id)} className="app-action-danger rounded-md px-1.5 py-0.5">удалить</button>}</div></div><p className="app-text-wrap text-[var(--foreground)]">{c.text}</p></div>))}</div>
<div className="mt-2 flex items-center gap-2"><input value={h.commentDrafts[item.id] || ""} onChange={(e) => h.setCommentDrafts((p) => ({ ...p, [item.id]: e.target.value }))} placeholder="Добавить комментарий" className="app-input flex-1 rounded-lg px-3 py-2 text-xs" /><button type="button" onClick={() => h.handleAddComment(item.id)} disabled={h.busyKey === `comment-${item.id}`} className="app-action-primary rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-60">Отправить</button></div>
</div>
)}
</div>
)}
</div>
</article>
);
})}
) : 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>}
@ -407,61 +246,34 @@ function RequestsPageContent() {
</section>
)}
{/* Detail modal */}
<Modal isOpen={Boolean(h.detailsRequest)} onClose={closeDetailsRequest} title="Полная информация по заявлению" size="lg">
{h.detailsRequest && (() => {
const dr = h.detailsRequest; const da = dr.employee || dr.created_by; const dap = dr.approver || dr.assigned_to;
const ds = statusMeta[String(dr.status || "").toLowerCase()] ?? defaultStatusMeta;
const dtl = requestTypeLabels[String(dr.type || dr.request_type || "").toLowerCase()] || String(dr.type || dr.request_type || "Другое");
const dau = dr.attachment_url || dr.attachment || ""; const dan = dau ? decodeURIComponent(dau.split("/").pop() || "Вложение") : "";
return (
<div className="space-y-5 text-sm text-[var(--foreground)]">
<div className="app-surface-muted rounded-xl p-4"><div className="space-y-3"><div className="flex flex-wrap items-center gap-2"><span className="app-badge app-badge-accent px-2.5 py-1 text-xs font-medium">{dtl}</span><span className={`inline-flex rounded-full px-2.5 py-1 text-xs ring-1 ${ds.className}`}>{ds.label}</span></div><h2 className="app-text-wrap text-lg font-semibold text-[var(--foreground)]">{dr.display_title || dr.title || "Без заголовка"}</h2>{(dr.comment || dr.description) ? <div className="app-text-wrap mt-3 whitespace-pre-wrap text-sm leading-relaxed text-[var(--foreground)]">{dr.comment || dr.description}</div> : <p className="app-text-muted mt-3 text-sm">Описание отсутствует</p>}</div></div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Даты</p><div className="mt-3 space-y-2"><div><p className="app-text-muted text-xs">Период</p><p className="mt-1 text-sm text-[var(--foreground)]">{dr.date_from ? formatDate(dr.date_from) : "—"}{dr.date_to ? ` — ${formatDate(dr.date_to)}` : ""}</p></div><div><p className="app-text-muted text-xs">Создано</p><p className="mt-1 text-sm text-[var(--foreground)]">{formatDateTime(dr.created_at) || "—"}</p></div><div><p className="app-text-muted text-xs">Обновлено</p><p className="mt-1 text-sm text-[var(--foreground)]">{formatDateTime(dr.updated_at) || "—"}</p></div></div></div>
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Участники</p><div className="mt-3 space-y-3"><div><p className="app-text-muted text-xs">Автор</p><div className="mt-2 flex flex-wrap gap-2">{da ? renderUserBadge(da as User, true) : <span className="app-text-muted text-sm">—</span>}</div></div><div><p className="app-text-muted text-xs">Решающий</p><div className="mt-2 flex flex-wrap gap-2">{dap ? renderUserBadge(dap as User, true) : <span className="app-text-muted text-sm">—</span>}</div></div></div></div>
</div>
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Отделы</p>{(dr.departments || []).length > 0 ? <div className="mt-3 flex flex-wrap gap-2">{(dr.departments || []).map((id) => <span key={id} className="app-badge px-3 py-1.5 text-sm font-medium">{h.departmentNameMap.get(Number(id)) || `Отдел #${id}`}</span>)}</div> : <p className="app-text-muted mt-3 text-sm">Отделы не указаны</p>}</div>
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Получатели</p>{(dr.recipients || []).length > 0 ? <div className="mt-3 flex flex-wrap gap-2">{(dr.recipients || []).map((r) => <div key={r.id}>{renderUserBadge(r, true)}</div>)}</div> : <p className="app-text-muted mt-3 text-sm">Получатели не указаны</p>}</div>
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">В копии</p>{(dr.cc_users || []).length > 0 ? <div className="mt-3 flex flex-wrap gap-2">{(dr.cc_users || []).map((c) => <div key={c.id}>{renderUserBadge(c, true)}</div>)}</div> : <p className="app-text-muted mt-3 text-sm">Копия не указана</p>}</div>
<div className="app-surface rounded-xl p-4"><p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Вложение</p>{dau ? <div className="mt-3 flex flex-wrap items-center gap-3"><button type="button" onClick={() => h.setAttachmentPreview({ url: dau, name: dan })} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"><Paperclip size={15} /><span className="app-text-wrap">{dan}</span></button><a href={dau} target="_blank" rel="noreferrer" className="app-link-accent text-sm font-medium hover:underline">Открыть в новой вкладке</a></div> : <p className="app-text-muted mt-3 text-sm">Вложение отсутствует</p>}</div>
</div>
);
})()}
</Modal>
<RequestDetailModal
currentUserId={user?.id}
departmentNameMap={h.departmentNameMap}
onClose={closeDetailsRequest}
onPreviewAttachment={h.setAttachmentPreview}
request={h.detailsRequest}
/>
{/* Create/Edit modal */}
<Modal isOpen={h.isModalOpen} onClose={h.closeModal} title={h.modalMode === "create" ? "Новое заявление" : "Редактировать заявление"} size="md" footer={
<div className="flex flex-wrap items-center justify-end gap-2">
<button type="button" onClick={h.closeModal} className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium">Отмена</button>
<button type="button" onClick={() => h.handleCreateOrUpdate(h.modalMode, "draft")} disabled={h.busyKey !== null} className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-60">Сохранить как черновик</button>
<button type="button" onClick={() => h.handleCreateOrUpdate(h.modalMode, "submitted")} disabled={h.busyKey !== null} className="app-action-primary rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-60">{h.modalMode === "create" ? "Создать" : "Сохранить"}</button>
</div>
}>
{h.actionError && <p className="app-feedback-danger mb-3 rounded-lg px-3 py-2 text-sm">{h.actionError}</p>}
<div className="flex flex-col gap-3">
<div><label className="app-text-muted mb-1 block text-xs font-medium">Тема заявления</label><input value={h.form.title} onChange={(e) => h.setForm((p) => ({ ...p, title: e.target.value }))} placeholder="Тема заявления" className="app-input w-full rounded-lg px-3 py-2 text-sm" /></div>
<div><label className="app-text-muted mb-1 block text-xs font-medium">Тип заявления</label><select value={h.form.type} onChange={(e) => h.setForm((p) => ({ ...p, type: e.target.value }))} className="app-select w-full rounded-lg px-3 py-2 text-sm"><option value="">Выберите тип</option>{Object.entries(requestTypeLabels).map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select></div>
<div><label className="app-text-muted mb-1 block text-xs font-medium">Период</label><div className="flex items-center gap-2"><input type="date" value={h.form.date_from} onChange={(e) => h.setForm((p) => ({ ...p, date_from: e.target.value }))} className="app-input flex-1 rounded-lg px-3 py-2 text-sm" /><span className="app-text-muted text-xs">—</span><input type="date" value={h.form.date_to} onChange={(e) => h.setForm((p) => ({ ...p, date_to: e.target.value }))} className="app-input flex-1 rounded-lg px-3 py-2 text-sm" /></div></div>
<SearchableSelectMulti label="Решающий" placeholder="Выберите решающего..." items={h.employees.filter((e) => !user?.id || e.id !== user.id).map((e) => ({ id: e.id, name: displayUserName(e) }))} selectedIds={h.form.recipient_ids} onToggle={(id) => h.setForm((p) => ({ ...p, recipient_ids: p.recipient_ids.includes(id) ? p.recipient_ids.filter((x) => x !== id) : [...p.recipient_ids, id] }))} />
<SearchableSelectMulti label="В копии" placeholder="Выберите пользователей..." items={h.employees.filter((e) => !user?.id || e.id !== user.id).map((e) => ({ id: e.id, name: displayUserName(e) }))} selectedIds={h.form.cc_user_ids} onToggle={(id) => h.setForm((p) => ({ ...p, cc_user_ids: p.cc_user_ids.includes(id) ? p.cc_user_ids.filter((x) => x !== id) : [...p.cc_user_ids, id] }))} />
<SearchableSelectMulti label="Отдел" placeholder="Выберите отдел..." items={h.departments.map((d) => ({ id: d.id, name: d.name }))} selectedIds={h.form.department_ids} onToggle={(id) => h.setForm((p) => ({ ...p, department_ids: p.department_ids.includes(id) ? p.department_ids.filter((x) => x !== id) : [...p.department_ids, id] }))} />
<div><label className="app-text-muted mb-1 block text-xs font-medium">Описание</label><textarea value={h.form.comment} onChange={(e) => h.setForm((p) => ({ ...p, comment: e.target.value }))} placeholder="Описание заявления" rows={3} className="app-input w-full rounded-lg px-3 py-2 text-sm" /></div>
<div><input ref={fileInputRef} type="file" accept=".pdf,.jpg,.jpeg,.png" className="hidden" onChange={(e) => { const f = e.target.files?.[0] || null; if (f) { const ext = f.name.split(".").pop()?.toLowerCase() || ""; if (!["pdf","jpg","jpeg","png"].includes(ext)) return; } h.setForm((p) => ({ ...p, attachment: f })); }} /><button type="button" onClick={() => fileInputRef.current?.click()} className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm"><Paperclip size={14} />{h.form.attachment ? h.form.attachment.name : "Прикрепить файл"}</button></div>
<label className="flex items-center gap-2 text-sm text-[var(--foreground)]"><input type="checkbox" checked={h.form.sent_to_all_department} onChange={(e) => h.setForm((p) => ({ ...p, sent_to_all_department: e.target.checked }))} className="rounded border-[var(--border-strong)]" />Отправить всем сотрудникам выбранных отделов</label>
</div>
</Modal>
{h.isModalOpen && (
<RequestComposeModal
actionError={h.actionError}
busyKey={h.busyKey}
currentUserId={user?.id}
editingRequest={h.editingRequest}
employees={h.employees}
form={h.form}
mode={h.modalMode}
onClose={h.closeModal}
onPreviewAttachment={h.setAttachmentPreview}
onSubmit={h.handleCreateOrUpdate}
setForm={h.setForm}
/>
)}
{/* Attachment preview */}
{h.attachmentPreview && (() => {
const apUrl = h.attachmentPreview.url;
const apName = h.attachmentPreview.name;
return (
<Modal isOpen onClose={() => h.setAttachmentPreview(null)} title={apName} size="lg" footer={<a href={apUrl} download className="app-action-secondary rounded-lg px-3 py-1.5 text-xs font-medium">Скачать</a>}>
<div className="flex-1 overflow-auto">{(() => { const ext = apUrl.split(".").pop()?.toLowerCase() || ""; if (["jpg","jpeg","png","gif","webp","svg","bmp"].includes(ext)) return <img src={apUrl} alt={apName} className="mx-auto max-h-[70vh] rounded-lg object-contain" />; if (["mp4","webm","ogg","mov"].includes(ext)) return <video src={apUrl} controls className="mx-auto max-h-[70vh] rounded-lg" />; if (["mp3","wav","aac"].includes(ext)) return <audio src={apUrl} controls className="mx-auto mt-8" />; return <div className="flex flex-col items-center gap-3 py-12 text-center"><FileSignature size={40} className="app-text-muted" /><p className="app-text-muted text-sm">{apName}</p><a href={apUrl} download className="app-action-primary rounded-lg px-4 py-2 text-sm font-medium">Скачать файл</a></div>; })()}</div>
</Modal>
);
})()}
<RequestAttachmentPreviewModal
preview={h.attachmentPreview}
onClose={() => h.setAttachmentPreview(null)}
/>
</AppShell>
);
}

View File

@ -0,0 +1,63 @@
import { Modal } from "@/components/ui";
import { FileSignature } from "lucide-react";
type RequestAttachmentPreviewModalProps = {
onClose: () => void;
preview: {
name: string;
url: string;
} | null;
};
export function RequestAttachmentPreviewModal({
onClose,
preview,
}: RequestAttachmentPreviewModalProps) {
if (!preview) return null;
const extension = preview.url.split(".").pop()?.toLowerCase() || "";
return (
<Modal
isOpen
onClose={onClose}
title={preview.name}
size="lg"
footer={(
<a
href={preview.url}
download
className="app-action-secondary rounded-lg px-3 py-1.5 text-xs font-medium"
>
Скачать
</a>
)}
>
<div className="flex-1 overflow-auto">
{["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp"].includes(extension) ? (
<img
src={preview.url}
alt={preview.name}
className="mx-auto max-h-[70vh] rounded-lg object-contain"
/>
) : ["mp4", "webm", "ogg", "mov"].includes(extension) ? (
<video src={preview.url} controls className="mx-auto max-h-[70vh] rounded-lg" />
) : ["mp3", "wav", "aac"].includes(extension) ? (
<audio src={preview.url} controls className="mx-auto mt-8" />
) : (
<div className="flex flex-col items-center gap-3 py-12 text-center">
<FileSignature size={40} className="app-text-muted" />
<p className="app-text-muted text-sm">{preview.name}</p>
<a
href={preview.url}
download
className="app-action-primary rounded-lg px-4 py-2 text-sm font-medium"
>
Скачать файл
</a>
</div>
)}
</div>
</Modal>
);
}

View File

@ -0,0 +1,342 @@
"use client";
import { Modal } from "@/components/ui";
import { SearchableSelectMulti, SearchableSelectSingle } from "@/components/shared/SearchableSelect";
import { getRequestDateMode, requestTypeLabels, type RequestFormState } from "@/hooks/useRequestsPage";
import { displayUserName } from "@/lib/shared";
import type { Request, User } from "@/types/api";
import { Paperclip, X } from "lucide-react";
import {
useMemo,
useRef,
useState,
type ChangeEvent,
type Dispatch,
type SetStateAction,
} from "react";
type AttachmentPreview = {
url: string;
name: string;
};
type RequestComposeModalProps = {
actionError: string | null;
busyKey: string | null;
currentUserId?: number | null;
editingRequest: Request | null;
employees: User[];
form: RequestFormState;
mode: "create" | "edit";
onClose: () => void;
onPreviewAttachment: (preview: AttachmentPreview) => void;
onSubmit: (mode: "create" | "edit", saveAs: "draft" | "submit") => void | Promise<void>;
setForm: Dispatch<SetStateAction<RequestFormState>>;
};
const allowedAttachmentExtensions = new Set(["pdf", "jpg", "jpeg", "png"]);
export function RequestComposeModal({
actionError,
busyKey,
currentUserId,
editingRequest,
employees,
form,
mode,
onClose,
onPreviewAttachment,
onSubmit,
setForm,
}: RequestComposeModalProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [showCcField, setShowCcField] = useState(() => form.cc_user_ids.length > 0);
const selectableEmployees = useMemo(
() => employees
.filter((employee) => !currentUserId || employee.id !== currentUserId)
.map((employee) => ({ id: employee.id, name: displayUserName(employee) })),
[currentUserId, employees],
);
const dateMode = getRequestDateMode(form.type);
const showOptionalDates = dateMode === "optional" && Boolean(form.date_from || form.date_to);
const existingAttachmentUrl = editingRequest?.attachment_url || editingRequest?.attachment || "";
const existingAttachmentName = existingAttachmentUrl
? decodeURIComponent(existingAttachmentUrl.split("/").pop() || "Вложение")
: "";
const toggleSelection = (field: "recipient_ids" | "cc_user_ids", id: number) => {
setForm((prev) => ({
...prev,
[field]: prev[field].includes(id)
? prev[field].filter((item) => item !== id)
: [...prev[field], id],
}));
};
const handleAttachmentChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] || null;
if (file) {
const extension = file.name.split(".").pop()?.toLowerCase() || "";
if (!allowedAttachmentExtensions.has(extension)) {
event.target.value = "";
return;
}
}
setForm((prev) => ({ ...prev, attachment: file }));
};
return (
<Modal
isOpen
onClose={onClose}
title={mode === "create" ? "Новое заявление" : "Редактировать заявление"}
size="lg"
className="h-[100dvh] max-w-full rounded-none sm:h-auto sm:rounded-2xl"
footer={(
<div className="border-t border-[var(--border-subtle)] pt-3 sm:pt-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="app-text-muted text-xs">
Черновик можно сохранить неполным. Для отправки нужны получатели и тип заявления.
</p>
<div className="flex flex-wrap items-center justify-end gap-2">
<button
type="button"
onClick={onClose}
className="app-action-secondary rounded-lg px-4 py-2.5 text-sm font-medium"
>
Отмена
</button>
<button
type="button"
onClick={() => void onSubmit(mode, "draft")}
disabled={busyKey !== null}
className="app-action-secondary rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-60"
>
{mode === "create" ? "Сохранить черновик" : "Сохранить как черновик"}
</button>
<button
type="button"
onClick={() => void onSubmit(mode, "submit")}
disabled={busyKey !== null}
className="app-action-primary rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-60"
>
Отправить
</button>
</div>
</div>
</div>
)}
>
{actionError && (
<p className="app-feedback-danger mb-4 rounded-xl px-4 py-3 text-sm">
{actionError}
</p>
)}
<div className="space-y-4 pb-2">
<section className="app-surface-muted rounded-xl p-3 sm:p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">Адресация</p>
<p className="app-text-muted text-xs">Решение принимает только пользователь из поля «Кому».</p>
</div>
{!showCcField && (
<button
type="button"
onClick={() => setShowCcField(true)}
className="app-link-accent text-xs font-medium"
>
Добавить копию
</button>
)}
</div>
<div className="space-y-2">
<SearchableSelectMulti
label="Кому"
layout="inline"
placeholder="Добавьте получателей"
items={selectableEmployees}
selectedIds={form.recipient_ids}
onToggle={(id) => toggleSelection("recipient_ids", id)}
/>
{showCcField && (
<SearchableSelectMulti
label="Копия"
layout="inline"
placeholder="Добавьте пользователей в копию"
items={selectableEmployees}
selectedIds={form.cc_user_ids}
onToggle={(id) => toggleSelection("cc_user_ids", id)}
/>
)}
</div>
</section>
<div className="space-y-3">
<div>
<label className="app-text-muted mb-1.5 block text-xs font-medium">Тема</label>
<input
value={form.title}
onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))}
placeholder="Например: Отпуск с 12 по 16 августа"
className="app-input w-full rounded-lg px-4 py-3 text-sm"
/>
</div>
<div>
<label className="app-text-muted mb-1.5 block text-xs font-medium">Сообщение</label>
<textarea
value={form.comment}
onChange={(event) => setForm((prev) => ({ ...prev, comment: event.target.value }))}
placeholder="Кратко опишите заявление. Для черновика можно оставить поле пустым."
rows={8}
className="app-input min-h-44 w-full rounded-lg px-4 py-3 text-sm leading-6"
/>
</div>
</div>
<section className="app-surface-muted rounded-xl p-3 sm:p-4">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<SearchableSelectSingle
label="Тип заявления"
placeholder="Выберите тип"
items={Object.entries(requestTypeLabels).map(([value, label]) => ({ id: value, name: label }))}
selectedId={form.type || null}
onSelect={(value) => setForm((prev) => ({
...prev,
type: typeof value === "string" ? value : "",
date_from: "",
date_to: "",
}))}
/>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<label className="app-text-muted block text-xs font-medium">
{dateMode === "range" ? "Период" : "Дата"}
</label>
{dateMode === "optional" && !showOptionalDates && (
<span className="app-text-muted text-[11px]">Даты не обязательны для этого типа</span>
)}
</div>
{dateMode === "range" ? (
<div className="grid gap-2 sm:grid-cols-2">
<input
type="date"
value={form.date_from}
onChange={(event) => setForm((prev) => ({ ...prev, date_from: event.target.value }))}
className="app-input rounded-lg px-4 py-3 text-sm"
/>
<input
type="date"
value={form.date_to}
onChange={(event) => setForm((prev) => ({ ...prev, date_to: event.target.value }))}
className="app-input rounded-lg px-4 py-3 text-sm"
/>
</div>
) : dateMode === "single" ? (
<input
type="date"
value={form.date_from}
onChange={(event) => setForm((prev) => ({ ...prev, date_from: event.target.value }))}
className="app-input w-full rounded-lg px-4 py-3 text-sm"
/>
) : showOptionalDates ? (
<div className="grid gap-2 sm:grid-cols-2">
<input
type="date"
value={form.date_from}
onChange={(event) => setForm((prev) => ({ ...prev, date_from: event.target.value }))}
className="app-input rounded-lg px-4 py-3 text-sm"
/>
<input
type="date"
value={form.date_to}
onChange={(event) => setForm((prev) => ({ ...prev, date_to: event.target.value }))}
className="app-input rounded-lg px-4 py-3 text-sm"
/>
</div>
) : (
<button
type="button"
onClick={() => setForm((prev) => ({
...prev,
date_from: prev.date_from || new Date().toISOString().slice(0, 10),
}))}
className="app-action-secondary rounded-lg px-4 py-3 text-sm font-medium"
>
Добавить дату
</button>
)}
</div>
</div>
<div className="mt-3 rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] p-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-[var(--foreground)]">Вложение</p>
<p className="app-text-muted mt-1 text-xs">
Прикрепите подтверждающий файл, если он нужен для заявления.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png"
className="hidden"
onChange={handleAttachmentChange}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-medium"
>
<Paperclip size={14} />
{form.attachment ? "Заменить файл" : "Прикрепить файл"}
</button>
</div>
</div>
{(form.attachment || existingAttachmentUrl) && (
<div className="mt-3 flex flex-wrap items-center gap-2">
{existingAttachmentUrl && !form.attachment && (
<button
type="button"
onClick={() => onPreviewAttachment({ url: existingAttachmentUrl, name: existingAttachmentName })}
className="app-badge app-badge-accent inline-flex max-w-full items-center gap-2 px-3 py-1.5 text-xs font-medium"
>
<Paperclip size={13} className="shrink-0" />
<span className="truncate">{existingAttachmentName}</span>
</button>
)}
{form.attachment && (
<span className="app-badge app-badge-accent inline-flex max-w-full items-center gap-2 px-3 py-1.5 text-xs font-medium">
<Paperclip size={13} className="shrink-0" />
<span className="truncate">{form.attachment.name}</span>
<button
type="button"
onClick={() => {
setForm((prev) => ({ ...prev, attachment: null }));
if (fileInputRef.current) fileInputRef.current.value = "";
}}
className="rounded-full p-0.5 transition hover:bg-[var(--accent-soft)]"
aria-label="Убрать файл"
>
<X size={12} />
</button>
</span>
)}
</div>
)}
</div>
</section>
</div>
</Modal>
);
}

View File

@ -0,0 +1,203 @@
import { Modal } from "@/components/ui";
import { defaultStatusMeta, requestTypeLabels, statusMeta } 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;
request: Request | null;
};
export function RequestDetailModal({
currentUserId,
departmentNameMap,
onClose,
onPreviewAttachment,
request,
}: RequestDetailModalProps) {
if (!request) return null;
const author = request.employee || request.created_by;
const approver = request.approver || request.assigned_to;
const status = statusMeta[String(request.status || "").toLowerCase()] ?? defaultStatusMeta;
const typeLabel = requestTypeLabels[String(request.type || request.request_type || "").toLowerCase()]
|| String(request.type || request.request_type || "Другое");
const attachmentUrl = request.attachment_url || request.attachment || "";
const attachmentName = attachmentUrl
? decodeURIComponent(attachmentUrl.split("/").pop() || "Вложение")
: "";
return (
<Modal
isOpen
onClose={onClose}
title="Полная информация по заявлению"
size="lg"
>
<div className="space-y-5 text-sm text-[var(--foreground)]">
<div className="app-surface-muted rounded-xl p-4">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="app-badge app-badge-accent px-2.5 py-1 text-xs font-medium">
{typeLabel}
</span>
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs ring-1 ${status.className}`}>
{status.label}
</span>
</div>
<h2 className="app-text-wrap text-lg font-semibold text-[var(--foreground)]">
{request.display_title || request.title || "Без заголовка"}
</h2>
{(request.comment || request.description) ? (
<div className="app-text-wrap mt-3 whitespace-pre-wrap text-sm leading-relaxed text-[var(--foreground)]">
{request.comment || request.description}
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Описание отсутствует</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Даты</p>
<div className="mt-3 space-y-2">
<div>
<p className="app-text-muted text-xs">Период</p>
<p className="mt-1 text-sm text-[var(--foreground)]">
{request.date_from ? formatDate(request.date_from) : "—"}
{request.date_to ? ` — ${formatDate(request.date_to)}` : ""}
</p>
</div>
<div>
<p className="app-text-muted text-xs">Создано</p>
<p className="mt-1 text-sm text-[var(--foreground)]">
{formatDateTime(request.created_at) || "—"}
</p>
</div>
<div>
<p className="app-text-muted text-xs">Обновлено</p>
<p className="mt-1 text-sm text-[var(--foreground)]">
{formatDateTime(request.updated_at) || "—"}
</p>
</div>
</div>
</div>
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Участники</p>
<div className="mt-3 space-y-3">
<div>
<p className="app-text-muted text-xs">Автор</p>
<div className="mt-2 flex flex-wrap gap-2">
{author ? (
<RequestUserBadge person={author} currentUserId={currentUserId} large />
) : (
<span className="app-text-muted text-sm">—</span>
)}
</div>
</div>
<div>
<p className="app-text-muted text-xs">Принял решение</p>
<div className="mt-2 flex flex-wrap gap-2">
{approver ? (
<RequestUserBadge person={approver} currentUserId={currentUserId} large />
) : (
<span className="app-text-muted text-sm">—</span>
)}
</div>
</div>
</div>
</div>
</div>
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Отделы</p>
{(request.departments || []).length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{(request.departments || []).map((id) => (
<span key={id} className="app-badge px-3 py-1.5 text-sm font-medium">
{departmentNameMap.get(Number(id)) || `Отдел #${id}`}
</span>
))}
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Отделы не указаны</p>
)}
</div>
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Получатели</p>
{(request.recipients || []).length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{(request.recipients || []).map((recipient) => (
<RequestUserBadge
key={recipient.id}
person={recipient}
currentUserId={currentUserId}
large
/>
))}
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Получатели не указаны</p>
)}
</div>
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">В копии</p>
{(request.cc_users || []).length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{(request.cc_users || []).map((ccUser) => (
<RequestUserBadge
key={ccUser.id}
person={ccUser}
currentUserId={currentUserId}
large
/>
))}
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Копия не указана</p>
)}
</div>
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Вложение</p>
{attachmentUrl ? (
<div className="mt-3 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => onPreviewAttachment({ url: attachmentUrl, name: attachmentName })}
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"
>
<Paperclip size={15} />
<span className="app-text-wrap">{attachmentName}</span>
</button>
<a
href={attachmentUrl}
target="_blank"
rel="noreferrer"
className="app-link-accent text-sm font-medium hover:underline"
>
Открыть в новой вкладке
</a>
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Вложение отсутствует</p>
)}
</div>
</div>
</Modal>
);
}

View File

@ -0,0 +1,243 @@
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";
type RequestListControlsProps = {
actionError: string | null;
actionSuccess: string | null;
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;
ordering: string;
pendingDecisionCount: number;
periodFromFilter: string;
periodToFilter: string;
search: string;
statusFilter: string;
typeFilter: string;
view: "" | "mine" | "addressed";
};
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,
}: 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">
<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}
</span>
) : null}
</button>
<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="Сортировка"
>
{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>
{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>
<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>
</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 ? (
<button
type="button"
onClick={onClearFilters}
className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium transition"
>
Очистить фильтры
</button>
) : null}
</div>
) : null}
</>
);
}

View File

@ -0,0 +1,405 @@
import Link from "next/link";
import { defaultStatusMeta, requestTypeLabels, statusMeta } 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;
comments: RequestComment[];
commentsOpen: boolean;
currentUserId?: number | null;
departmentNameMap: Map<number, string>;
isFinal: (status?: string) => boolean;
isMenuOpen: boolean;
menuRef?: Ref<HTMLDivElement> | null;
onAddComment: (requestId: number) => void | Promise<void>;
onApprove: (requestId: number) => void | Promise<void>;
onCancel: (requestId: number) => void | Promise<void>;
onDelete: (requestId: number) => void | Promise<void>;
onDeleteComment: (requestId: number, commentId: number) => void | Promise<void>;
onEdit: (request: Request) => void;
onOpenDetails: (request: Request) => void;
onPreviewAttachment: (preview: AttachmentPreview) => void;
onReject: (requestId: number) => void | Promise<void>;
onSetCommentDraft: (requestId: number, value: string) => void;
onToggleComments: (requestId: number) => void | Promise<void>;
onToggleMenu: (requestId: number | null) => void;
onToggleRow: (requestId: number) => void;
request: Request;
rowOpen: boolean;
};
export function RequestListItem({
busyKey,
commentDraft,
comments,
commentsOpen,
currentUserId,
departmentNameMap,
isFinal,
isMenuOpen,
menuRef = null,
onAddComment,
onApprove,
onCancel,
onDelete,
onDeleteComment,
onEdit,
onOpenDetails,
onPreviewAttachment,
onReject,
onSetCommentDraft,
onToggleComments,
onToggleMenu,
onToggleRow,
request,
rowOpen,
}: 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 typeLabel = requestTypeLabels[typeKey] || String(request.type || request.request_type || "Другое");
const title = request.display_title || request.title || "Без заголовка";
const canProcess = Boolean(statusKey === "pending" && request.can_decide);
const isAuthor = Boolean(requestAuthor?.id && currentUserId && requestAuthor.id === currentUserId);
const canCancel = Boolean(isAuthor && !isFinal(statusKey));
const canEdit = Boolean(isAuthor && !isFinal(statusKey));
const canDelete = Boolean(isAuthor && !isFinal(statusKey));
const hasSecondaryActions = canCancel || canEdit || canDelete;
const canComment = statusKey !== "draft";
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() || "Вложение")
: "";
return (
<article className={`app-surface-muted rounded-xl transition hover:border-[var(--border-strong)] ${isMenuOpen ? "relative z-20 overflow-visible" : "overflow-hidden"}`}>
<div className="p-4">
<div className="flex items-start gap-3">
<div className="flex shrink-0 flex-col items-center gap-3 pt-0.5">
<button
type="button"
onClick={() => onToggleRow(request.id)}
className="app-action-secondary inline-flex h-8 w-8 items-center justify-center rounded-lg transition"
>
<ChevronDown size={15} className={`transition ${rowOpen ? "rotate-180" : ""}`} />
</button>
<button
type="button"
title={canComment ? `Комментарии (${request.comments_count ?? comments.length})` : "Комментарии для черновика недоступны"}
onClick={() => canComment && void onToggleComments(request.id)}
disabled={!canComment}
className="app-action-secondary relative inline-flex h-8 w-8 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-50"
>
<MessageSquare size={15} />
{(request.comments_count ?? comments.length) > 0 && (
<span className="app-counter absolute -right-1.5 -top-1.5 flex h-4 min-w-4 px-1 text-[10px] font-bold">
{request.comments_count ?? comments.length}
</span>
)}
</button>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-center gap-2">
{authorLink ? (
<Link href={authorLink} className="group flex min-w-0 items-center gap-2">
{requestAuthor?.avatar ? (
<img
src={requestAuthor.avatar}
alt={authorName}
className="app-avatar-frame h-8 w-8 shrink-0 rounded-full object-cover"
/>
) : (
<span className="app-avatar-fallback flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
{(requestAuthor?.first_name?.[0] || requestAuthor?.last_name?.[0] || "?").toUpperCase()}
</span>
)}
<span className="truncate text-sm font-medium text-[var(--foreground)] group-hover:text-[var(--accent-primary-strong)]">
{authorName}
</span>
</Link>
) : (
<div className="flex min-w-0 items-center gap-2">
<span className="app-badge flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold">?</span>
<span className="truncate text-sm font-medium text-[var(--foreground)]">{authorName}</span>
</div>
)}
</div>
<button type="button" onClick={() => onOpenDetails(request)} className="block w-full text-left">
<h3 className={`${rowOpen ? "app-text-wrap line-clamp-3" : "truncate"} text-sm font-semibold text-[var(--foreground)] transition hover:text-[var(--accent-primary-strong)]`}>
<span className="app-text-muted">{typeLabel}:</span>{" "}
<span className="text-[var(--foreground)]">{title}</span>
</h3>
</button>
<div className="app-text-muted mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span>
Период: {request.date_from ? formatDate(request.date_from) : "—"}
{request.date_to ? ` — ${formatDate(request.date_to)}` : ""}
</span>
</div>
</div>
<div className="shrink-0">
<div ref={isMenuOpen ? menuRef : null} className="flex items-center justify-end gap-2">
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs ring-1 ${status.className}`}>
{status.label}
</span>
{hasSecondaryActions ? (
<div className="relative">
<button
type="button"
onClick={() => onToggleMenu(isMenuOpen ? null : request.id)}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-lg"
title="Действия с заявлением"
aria-label="Действия с заявлением"
aria-expanded={isMenuOpen}
aria-haspopup="menu"
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${isMenuOpen ? "rotate-90" : ""}`}
/>
</button>
{isMenuOpen ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-44 rounded-xl py-1.5">
{canCancel ? (
<button
type="button"
disabled={busyKey === `cancel-${request.id}`}
onClick={() => {
onToggleMenu(null);
void onCancel(request.id);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:opacity-50"
>
<Ban size={14} />
Отменить
</button>
) : null}
{canEdit ? (
<button
type="button"
onClick={() => {
onToggleMenu(null);
onEdit(request);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} />
Редактировать
</button>
) : null}
{canDelete ? (
<button
type="button"
disabled={busyKey === `delete-${request.id}`}
onClick={() => {
onToggleMenu(null);
void onDelete(request.id);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)] disabled:opacity-50"
>
<Trash2 size={14} />
Удалить
</button>
) : null}
</div>
) : null}
</div>
) : null}
</div>
</div>
</div>
{summary ? (
<p className={`${rowOpen ? "app-text-wrap line-clamp-10" : "app-text-wrap line-clamp-3"} mt-3 text-sm text-[var(--foreground)]`}>
{summary}
</p>
) : null}
{canProcess ? (
<div className={`${summary ? "mt-3" : "mt-2"} flex flex-wrap items-center gap-1.5`}>
<span className="ml-auto inline-flex items-center gap-2">
<button
type="button"
title="Одобрить"
onClick={() => void onApprove(request.id)}
disabled={busyKey === `approve-${request.id}`}
className="app-feedback-success inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"
>
<ThumbsUp size={18} />
</button>
<button
type="button"
title="Отклонить"
onClick={() => void onReject(request.id)}
disabled={busyKey === `reject-${request.id}`}
className="app-action-danger inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"
>
<ThumbsDown size={18} />
</button>
</span>
</div>
) : null}
</div>
</div>
{(rowOpen || commentsOpen) ? (
<div className="app-surface-elevated mt-4 rounded-xl p-4">
{rowOpen ? (
<div className="space-y-3 text-xs">
<div className="grid grid-cols-1 gap-x-4 gap-y-2 sm:grid-cols-2">
<div>
<span className="app-text-muted">Принял решение:</span>{" "}
{(() => {
const approver = request.approver || request.assigned_to;
if (!approver) return <span className="app-text-muted">—</span>;
const approverLink = userProfileLink(approver, currentUserId);
const approverName = displayUserName(approver);
return approverLink
? <Link href={approverLink} className="app-link-accent font-medium">{approverName}</Link>
: <span className="font-medium text-[var(--foreground)]">{approverName}</span>;
})()}
</div>
<div>
<span className="app-text-muted">Создано:</span>{" "}
<span className="font-medium text-[var(--foreground)]">{formatDate(request.created_at) || "—"}</span>
</div>
<div>
<span className="app-text-muted">Обновлено:</span>{" "}
<span className="font-medium text-[var(--foreground)]">{formatDate(request.updated_at) || "—"}</span>
</div>
{departmentLabels ? (
<div className="sm:col-span-2">
<span className="app-text-muted">Отделы:</span>{" "}
<span className="font-medium text-[var(--foreground)]">{departmentLabels}</span>
</div>
) : null}
</div>
<div className="space-y-2">
<div className="flex flex-wrap items-start gap-2">
<span className="app-text-muted pt-1">Получатели:</span>
<div className="flex min-w-0 flex-1 flex-wrap gap-1.5">
{recipients.slice(0, 2).map((recipient) => (
<RequestUserBadge
key={recipient.id}
person={recipient}
currentUserId={currentUserId}
/>
))}
{recipients.length === 0 ? (
<span className="app-text-muted pt-1">{request.recipient_count ?? 0}</span>
) : null}
{recipients.length > 2 ? (
<span className="app-badge px-2 py-1 text-xs font-medium">+{recipients.length - 2}</span>
) : null}
</div>
</div>
<div className="flex flex-wrap items-start gap-2">
<span className="app-text-muted pt-1">В копии:</span>
<div className="flex min-w-0 flex-1 flex-wrap gap-1.5">
{ccUsers.slice(0, 2).map((ccUser) => (
<RequestUserBadge
key={ccUser.id}
person={ccUser}
currentUserId={currentUserId}
/>
))}
{ccUsers.length === 0 ? <span className="app-text-muted pt-1">—</span> : null}
{ccUsers.length > 2 ? (
<span className="app-badge px-2 py-1 text-xs font-medium">+{ccUsers.length - 2}</span>
) : null}
</div>
</div>
{attachmentUrl ? (
<div className="flex min-w-0 items-center gap-1.5">
<button
type="button"
onClick={() => onPreviewAttachment({ url: attachmentUrl, name: attachmentName })}
className="app-badge app-badge-accent inline-flex min-w-0 max-w-full items-center gap-1.5 px-2.5 py-1 text-xs font-medium"
>
<Paperclip size={13} className="shrink-0" />
<span className="truncate">{attachmentName}</span>
</button>
</div>
) : null}
</div>
</div>
) : null}
{commentsOpen ? (
<div className={rowOpen ? "app-surface mt-3 rounded-xl p-3" : "app-surface rounded-xl p-3"}>
<div className="space-y-2">
{comments.length === 0 ? (
<p className="app-text-muted text-xs">Комментариев пока нет</p>
) : comments.map((comment) => (
<div key={comment.id} className="app-surface-muted rounded-lg px-3 py-2 text-xs text-[var(--foreground)]">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="font-medium">{displayUserName(comment.author)}</span>
<div className="flex items-center gap-2">
<span className="app-text-muted">{formatDate(comment.created_at)}</span>
{Boolean(comment.author?.id && currentUserId === comment.author.id) ? (
<button
type="button"
onClick={() => void onDeleteComment(request.id, comment.id)}
className="app-action-danger rounded-lg px-1.5 py-0.5"
>
удалить
</button>
) : null}
</div>
</div>
<p className="app-text-wrap text-[var(--foreground)]">{comment.text}</p>
</div>
))}
</div>
<div className="mt-2 flex items-center gap-2">
<input
value={commentDraft}
onChange={(event) => onSetCommentDraft(request.id, event.target.value)}
placeholder="Добавить комментарий"
className="app-input flex-1 rounded-lg px-3 py-2 text-xs"
/>
<button
type="button"
onClick={() => void onAddComment(request.id)}
disabled={busyKey === `comment-${request.id}`}
className="app-action-primary rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-60"
>
Отправить
</button>
</div>
</div>
) : null}
</div>
) : null}
</div>
</article>
);
}

View File

@ -0,0 +1,42 @@
import Link from "next/link";
import { displayUserName, userProfileLink } from "@/lib/shared";
import type { User } from "@/types/api";
type RequestUserBadgeProps = {
currentUserId?: number | null;
large?: boolean;
person: User;
};
export function RequestUserBadge({
currentUserId,
large = false,
person,
}: RequestUserBadgeProps) {
const personLink = userProfileLink(person, currentUserId);
const personName = displayUserName(person);
const chip = (
<>
{person.avatar ? (
<img
src={person.avatar}
alt={personName}
className={`app-avatar-frame ${large ? "h-7 w-7" : "h-6 w-6"} shrink-0 rounded-full object-cover`}
/>
) : (
<span
className={`app-avatar-fallback ${large ? "h-7 w-7 text-xs" : "h-6 w-6 text-[11px]"} flex shrink-0 items-center justify-center rounded-full font-semibold`}
>
{(person.first_name?.[0] || person.last_name?.[0] || "?").toUpperCase()}
</span>
)}
<span className="break-words">{personName}</span>
</>
);
const className = `app-badge inline-flex max-w-full items-center gap-2 rounded-full ${large ? "px-3 py-1.5 text-sm" : "px-2.5 py-1 text-xs"} font-medium`;
return personLink
? <Link href={personLink} className={`${className} hover:bg-[var(--surface-tertiary)]`}>{chip}</Link>
: <span className={className}>{chip}</span>;
}

View File

@ -4,7 +4,7 @@ import { ChevronDown } from "lucide-react";
import { useEffect, useRef, useState } from "react";
/* ── Single-select dropdown with search ── */
export function SearchableSelectSingle({
export function SearchableSelectSingle<T extends string | number>({
label,
items,
selectedId,
@ -13,9 +13,9 @@ export function SearchableSelectSingle({
disabled = false,
}: {
label: string;
items: { id: number; name: string }[];
selectedId: number | null;
onSelect: (id: number | null) => void;
items: { id: T; name: string }[];
selectedId: T | null;
onSelect: (id: T | null) => void;
placeholder?: string;
disabled?: boolean;
}) {
@ -60,7 +60,7 @@ export function SearchableSelectSingle({
/>
</div>
<div className="max-h-40 overflow-y-auto p-1">
{selectedId && (
{selectedId !== null && (
<button
type="button"
onClick={() => { onSelect(null); setOpen(false); }}
@ -97,12 +97,16 @@ export function SearchableSelectMulti({
selectedIds,
onToggle,
placeholder,
layout = "stacked",
className = "",
}: {
label: string;
items: { id: number; name: string }[];
selectedIds: number[];
onToggle: (id: number) => void;
placeholder?: string;
layout?: "stacked" | "inline";
className?: string;
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
@ -117,23 +121,40 @@ export function SearchableSelectMulti({
}, []);
const filtered = items.filter((i) => i.name.toLowerCase().includes(q.toLowerCase()));
const selectedNames = items.filter((i) => selectedIds.includes(i.id)).map((i) => i.name);
const selectedItems = items.filter((i) => selectedIds.includes(i.id));
const selectedNames = selectedItems.map((i) => i.name);
const isInline = layout === "inline";
return (
<div ref={ref} className="relative">
<label className="app-text-muted mb-1 block text-xs font-medium">{label}</label>
<div ref={ref} className={`relative ${className}`}>
{!isInline && <label className="app-text-muted mb-1 block text-xs font-medium">{label}</label>}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="app-select flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm"
className={isInline
? "app-input flex w-full items-start gap-3 rounded-2xl px-3 py-3 text-left text-sm"
: "app-select flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm"}
>
<span className="truncate">
{selectedNames.length > 0 ? selectedNames.join(", ") : <span className="app-text-muted">{placeholder || "Выбрать..."}</span>}
</span>
<ChevronDown size={14} className={`app-text-muted ml-2 shrink-0 transition ${open ? "rotate-180" : ""}`} />
{isInline ? (
<>
<span className="app-text-muted w-14 shrink-0 pt-0.5 text-sm font-medium">{label}</span>
<span className="flex min-w-0 flex-1 flex-wrap gap-1.5">
{selectedItems.length > 0 ? selectedItems.map((item) => (
<span key={item.id} className="app-badge inline-flex max-w-full items-center rounded-full px-2.5 py-1 text-xs font-medium">
<span className="truncate">{item.name}</span>
</span>
)) : <span className="app-text-muted py-0.5">{placeholder || "Выбрать..."}</span>}
</span>
</>
) : (
<span className="truncate">
{selectedNames.length > 0 ? selectedNames.join(", ") : <span className="app-text-muted">{placeholder || "Выбрать..."}</span>}
</span>
)}
<ChevronDown size={14} className={`app-text-muted ${isInline ? "mt-1" : "ml-2"} shrink-0 transition ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className="app-menu absolute z-50 mt-1 max-h-56 w-full overflow-hidden rounded-lg">
<div className={`app-menu absolute z-50 mt-1 max-h-56 overflow-hidden rounded-lg ${isInline ? "inset-x-0" : "w-full"}`}>
<div className="app-divider border-b p-2">
<input
value={q}

View File

@ -12,25 +12,21 @@ export type RequestFormState = {
date_from: string;
date_to: string;
comment: string;
department_ids: number[];
recipient_ids: number[];
cc_user_ids: number[];
sent_to_all_department: boolean;
attachment: File | null;
};
export const emptyForm: RequestFormState = {
export const createEmptyForm = (): RequestFormState => ({
type: "",
title: "",
date_from: "",
date_to: "",
comment: "",
department_ids: [],
recipient_ids: [],
cc_user_ids: [],
sent_to_all_department: false,
attachment: null,
};
});
export const statusMeta: Record<string, { label: string; className: string }> = {
draft: { label: "Черновик", className: "bg-slate-100 text-slate-700 ring-slate-200" },
@ -61,6 +57,45 @@ export const orderingOptions = [
{ value: "-date_from", label: "По периоду ↓" },
];
export function getRequestDateMode(type: string): "range" | "single" | "optional" {
switch (String(type || "").toLowerCase()) {
case "vacation":
case "sick_leave":
return "range";
case "transfer":
case "dismissal":
return "single";
default:
return "optional";
}
}
function getRequestSubmitError(form: RequestFormState): string | null {
if (form.recipient_ids.length === 0) {
return "Укажите хотя бы одного получателя.";
}
if (!form.type) {
return "Выберите тип заявления.";
}
const dateMode = getRequestDateMode(form.type);
if (dateMode === "range") {
if (!form.date_from || !form.date_to) {
return "Укажите обе даты периода.";
}
if (form.date_to < form.date_from) {
return "Дата окончания не может быть раньше даты начала.";
}
}
if (dateMode === "single" && !form.date_from) {
return "Укажите дату начала.";
}
return null;
}
/* ── hook ── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function useRequestsPage(_userId: number | null | undefined) {
@ -75,8 +110,8 @@ export function useRequestsPage(_userId: number | null | undefined) {
/* form */
const [createOpen, setCreateOpen] = useState(false);
const [editingRequestId, setEditingRequestId] = useState<number | null>(null);
const [form, setForm] = useState<RequestFormState>(emptyForm);
const [editingRequest, setEditingRequest] = useState<Request | null>(null);
const [form, setForm] = useState<RequestFormState>(createEmptyForm);
/* filters */
const [search, setSearch] = useState("");
@ -196,10 +231,10 @@ export function useRequestsPage(_userId: number | null | undefined) {
}, [ordering, requests, search]);
/* ── form ── */
const resetForm = () => setForm(emptyForm);
const resetForm = () => setForm(createEmptyForm());
const openEdit = (req: Request) => {
setEditingRequestId(req.id);
setEditingRequest(req);
setCreateOpen(false);
setActionError(null);
setActionSuccess(null);
@ -209,16 +244,14 @@ export function useRequestsPage(_userId: number | null | undefined) {
date_from: req.date_from || "",
date_to: req.date_to || "",
comment: req.comment || req.description || "",
department_ids: (req.departments || []).map(Number).filter((n) => Number.isFinite(n)),
recipient_ids: (req.recipients || []).map((u: User) => u.id).filter(Boolean),
cc_user_ids: (req.cc_users || []).map((u: User) => u.id).filter(Boolean),
sent_to_all_department: Boolean(req.sent_to_all_department),
attachment: null,
});
};
const openCreate = () => {
setEditingRequestId(null);
setEditingRequest(null);
resetForm();
setActionError(null);
setActionSuccess(null);
@ -227,45 +260,45 @@ export function useRequestsPage(_userId: number | null | undefined) {
const closeModal = () => {
setCreateOpen(false);
setEditingRequestId(null);
setEditingRequest(null);
resetForm();
setActionError(null);
};
/* ── CRUD ── */
const handleCreateOrUpdate = async (mode: "create" | "edit", saveAs: "draft" | "submitted") => {
const handleCreateOrUpdate = async (mode: "create" | "edit", saveAs: "draft" | "submit") => {
try {
setBusyKey(`${mode}-${saveAs}`);
setActionError(null);
setActionSuccess(null);
if (saveAs === "submitted" && !form.sent_to_all_department && form.department_ids.length === 0 && form.recipient_ids.length === 0) {
setActionError("Укажите получателей или отделы.");
return;
if (saveAs === "submit") {
const submitError = getRequestSubmitError(form);
if (submitError) {
setActionError(submitError);
return;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const payload: Record<string, any> = {
type: form.type,
const payload: Record<string, string | File | number[] | null> = {
title: form.title,
date_from: form.date_from || null,
date_to: form.date_to || null,
comment: form.comment,
sent_to_all_department: form.sent_to_all_department,
};
if (form.department_ids.length > 0) payload.department_ids = form.department_ids;
if (form.type) payload.type = form.type;
if (form.recipient_ids.length > 0) payload.recipient_ids = form.recipient_ids;
if (form.cc_user_ids.length > 0) payload.cc_user_ids = form.cc_user_ids;
if (form.attachment) payload.attachment = form.attachment;
if (mode === "create") {
await apiClient.createRequest(payload, saveAs);
setActionSuccess(saveAs === "draft" ? "Черновик сохранён." : "Заявление создано.");
setActionSuccess(saveAs === "draft" ? "Черновик сохранён." : "Заявление отправлено.");
setCreateOpen(false);
} else if (editingRequestId) {
await apiClient.updateRequest(editingRequestId, payload, saveAs);
setActionSuccess("Заявление обновлено.");
setEditingRequestId(null);
} else if (editingRequest) {
await apiClient.updateRequest(editingRequest.id, payload, saveAs);
setActionSuccess(saveAs === "draft" ? "Черновик обновлён." : "Заявление отправлено.");
setEditingRequest(null);
}
resetForm();
@ -275,13 +308,23 @@ export function useRequestsPage(_userId: number | null | undefined) {
} catch (e: unknown) {
const raw = String((e as Error)?.message || "Не удалось сохранить заявление");
let readable = raw;
try { const parsed = JSON.parse(raw); readable = Object.values(parsed).flat().join(". "); } catch { /* keep raw */ }
try {
const parsed = JSON.parse(raw) as Record<string, string[] | string>;
readable = Object.values(parsed).flat().join(". ");
} catch {
/* keep raw */
}
setActionError(readable);
} finally {
setBusyKey(null);
}
};
const applyUpdatedRequest = useCallback((updated: Request) => {
setRequests((prev) => prev.map((request) => (request.id === updated.id ? { ...request, ...updated } : request)));
setDetailsRequest((prev) => (prev?.id === updated.id ? { ...prev, ...updated } : prev));
}, []);
const handleLoadMore = useCallback(async () => {
if (!nextPage || loadingMore) return;
try {
@ -302,19 +345,19 @@ export function useRequestsPage(_userId: number | null | undefined) {
}, [nextPage, loadingMore, buildRequestParams]);
const handleApprove = async (id: number) => {
try { setBusyKey(`approve-${id}`); setActionError(null); await apiClient.approveRequest(id); setRequests((p) => p.map((r) => (r.id === id ? { ...r, status: "approved" } : r))); }
try { setBusyKey(`approve-${id}`); setActionError(null); const updated = await apiClient.approveRequest(id); applyUpdatedRequest(updated); }
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось одобрить")); }
finally { setBusyKey(null); }
};
const handleReject = async (id: number) => {
try { setBusyKey(`reject-${id}`); setActionError(null); await apiClient.rejectRequest(id); setRequests((p) => p.map((r) => (r.id === id ? { ...r, status: "rejected" } : r))); }
try { setBusyKey(`reject-${id}`); setActionError(null); const updated = await apiClient.rejectRequest(id); applyUpdatedRequest(updated); }
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось отклонить")); }
finally { setBusyKey(null); }
};
const handleCancel = async (id: number) => {
try { setBusyKey(`cancel-${id}`); setActionError(null); await apiClient.cancelRequest(id); setRequests((p) => p.map((r) => (r.id === id ? { ...r, status: "cancelled" } : r))); }
try { setBusyKey(`cancel-${id}`); setActionError(null); const updated = await apiClient.cancelRequest(id); applyUpdatedRequest(updated); }
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось отменить")); }
finally { setBusyKey(null); }
};
@ -323,7 +366,13 @@ export function useRequestsPage(_userId: number | null | undefined) {
if (!window.confirm("Удалить заявление? Это действие нельзя отменить.")) {
return;
}
try { setBusyKey(`delete-${id}`); setActionError(null); await apiClient.deleteRequest(id); setRequests((p) => p.filter((r) => r.id !== id)); }
try {
setBusyKey(`delete-${id}`);
setActionError(null);
await apiClient.deleteRequest(id);
setRequests((p) => p.filter((r) => r.id !== id));
setDetailsRequest((prev) => (prev?.id === id ? null : prev));
}
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось удалить")); }
finally { setBusyKey(null); }
};
@ -391,9 +440,10 @@ export function useRequestsPage(_userId: number | null | undefined) {
form,
setForm,
createOpen,
editingRequestId,
modalMode: (editingRequestId ? "edit" : "create") as "create" | "edit",
isModalOpen: createOpen || editingRequestId !== null,
editingRequest,
editingRequestId: editingRequest?.id ?? null,
modalMode: (editingRequest ? "edit" : "create") as "create" | "edit",
isModalOpen: createOpen || editingRequest !== null,
openCreate,
openEdit,
closeModal,

View File

@ -2,7 +2,7 @@
import type { RequestFn, GetTokenFn } from './utils';
export function createRequestsApi(request: RequestFn, getToken: GetTokenFn) {
async function formDataRequest(url: string, method: string, data: Record<string, any>, saveAs?: 'draft' | 'submitted') {
async function formDataRequest(url: string, method: string, data: Record<string, any>, saveAs?: 'draft' | 'submit') {
const fd = new FormData();
Object.keys(data).forEach((key) => {
if (key === 'attachments' && Array.isArray(data[key])) { data[key].forEach((f: File) => fd.append('attachments', f)); }
@ -30,8 +30,8 @@ export function createRequestsApi(request: RequestFn, getToken: GetTokenFn) {
return request(`/api/v1/requests/${qs ? '?' + qs : ''}`);
},
getRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/`),
createRequest: (data: Record<string, any>, saveAs?: 'draft' | 'submitted') => formDataRequest('/api/v1/requests/', 'POST', data, saveAs),
updateRequest: (requestId: number, data: Record<string, any>, saveAs?: 'draft' | 'submitted') => formDataRequest(`/api/v1/requests/${requestId}/`, 'PATCH', data, saveAs),
createRequest: (data: Record<string, any>, saveAs?: 'draft' | 'submit') => formDataRequest('/api/v1/requests/', 'POST', data, saveAs),
updateRequest: (requestId: number, data: Record<string, any>, saveAs?: 'draft' | 'submit') => formDataRequest(`/api/v1/requests/${requestId}/`, 'PATCH', data, saveAs),
deleteRequest: (requestId: number): Promise<void> => request(`/api/v1/requests/${requestId}/`, { method: 'DELETE' }),
getRequestComments: (requestId: number) => request(`/api/v1/requests/${requestId}/comments/`),
deleteRequestComment: (requestId: number, commentId: number): Promise<void> => request(`/api/v1/requests/${requestId}/comments/${commentId}/`, { method: 'DELETE' }),

View File

@ -298,6 +298,7 @@ export interface Request {
recipient_count?: number;
cc_count?: number;
is_recipient?: boolean;
can_decide?: boolean;
comments_count?: number;
is_final?: boolean;
attachment?: string | null;