refactor(frontend): decompose requests screen
This commit is contained in:
parent
bac7c65062
commit
331dc1433a
117
docs/guides/frontend/01-OVERVIEW.md
Normal file
117
docs/guides/frontend/01-OVERVIEW.md
Normal 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-паттернам
|
||||
150
docs/guides/frontend/02-FOUNDATION.md
Normal file
150
docs/guides/frontend/02-FOUNDATION.md
Normal 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
|
||||
120
docs/guides/frontend/03-GEOMETRY.md
Normal file
120
docs/guides/frontend/03-GEOMETRY.md
Normal 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 конструкцию
|
||||
156
docs/guides/frontend/04-PATTERNS.md
Normal file
156
docs/guides/frontend/04-PATTERNS.md
Normal 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
|
||||
76
docs/guides/frontend/05-MIGRATION.md
Normal file
76
docs/guides/frontend/05-MIGRATION.md
Normal 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.
|
||||
250
docs/guides/frontend/06-COMPONENT-INVENTORY.md
Normal file
250
docs/guides/frontend/06-COMPONENT-INVENTORY.md
Normal 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-компонент
|
||||
- не пытаемся ли мы решить локальную проблему новым глобальным паттерном
|
||||
147
docs/guides/frontend/07-DEVELOPER-WORKFLOW.md
Normal file
147
docs/guides/frontend/07-DEVELOPER-WORKFLOW.md
Normal 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
|
||||
64
docs/guides/frontend/08-UI-REVIEW-CHECKLIST.md
Normal file
64
docs/guides/frontend/08-UI-REVIEW-CHECKLIST.md
Normal 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 есть много “нет”, задача не считается просто “визуально нормальной”. Она должна либо быть доделана, либо иметь явный зафиксированный компромисс.
|
||||
46
docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md
Normal file
46
docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md
Normal 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`, новый код внутри неё особенно не должен копировать старые решения “как есть”.
|
||||
104
docs/guides/frontend/README.md
Normal file
104
docs/guides/frontend/README.md
Normal 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`.
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
342
frontend/src/components/requests/RequestComposeModal.tsx
Normal file
342
frontend/src/components/requests/RequestComposeModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
203
frontend/src/components/requests/RequestDetailModal.tsx
Normal file
203
frontend/src/components/requests/RequestDetailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
frontend/src/components/requests/RequestListControls.tsx
Normal file
243
frontend/src/components/requests/RequestListControls.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
405
frontend/src/components/requests/RequestListItem.tsx
Normal file
405
frontend/src/components/requests/RequestListItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
frontend/src/components/requests/RequestUserBadge.tsx
Normal file
42
frontend/src/components/requests/RequestUserBadge.tsx
Normal 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>;
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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' }),
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user