diff --git a/docs/guides/frontend/01-OVERVIEW.md b/docs/guides/frontend/01-OVERVIEW.md new file mode 100644 index 00000000..eac4ac8f --- /dev/null +++ b/docs/guides/frontend/01-OVERVIEW.md @@ -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-паттернам diff --git a/docs/guides/frontend/02-FOUNDATION.md b/docs/guides/frontend/02-FOUNDATION.md new file mode 100644 index 00000000..c8ccfb64 --- /dev/null +++ b/docs/guides/frontend/02-FOUNDATION.md @@ -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 diff --git a/docs/guides/frontend/03-GEOMETRY.md b/docs/guides/frontend/03-GEOMETRY.md new file mode 100644 index 00000000..a430dbfc --- /dev/null +++ b/docs/guides/frontend/03-GEOMETRY.md @@ -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 конструкцию diff --git a/docs/guides/frontend/04-PATTERNS.md b/docs/guides/frontend/04-PATTERNS.md new file mode 100644 index 00000000..947bedbd --- /dev/null +++ b/docs/guides/frontend/04-PATTERNS.md @@ -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 diff --git a/docs/guides/frontend/05-MIGRATION.md b/docs/guides/frontend/05-MIGRATION.md new file mode 100644 index 00000000..2c2628ee --- /dev/null +++ b/docs/guides/frontend/05-MIGRATION.md @@ -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. diff --git a/docs/guides/frontend/06-COMPONENT-INVENTORY.md b/docs/guides/frontend/06-COMPONENT-INVENTORY.md new file mode 100644 index 00000000..eb0e42d1 --- /dev/null +++ b/docs/guides/frontend/06-COMPONENT-INVENTORY.md @@ -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-компонент +- не пытаемся ли мы решить локальную проблему новым глобальным паттерном diff --git a/docs/guides/frontend/07-DEVELOPER-WORKFLOW.md b/docs/guides/frontend/07-DEVELOPER-WORKFLOW.md new file mode 100644 index 00000000..0a44bda3 --- /dev/null +++ b/docs/guides/frontend/07-DEVELOPER-WORKFLOW.md @@ -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 diff --git a/docs/guides/frontend/08-UI-REVIEW-CHECKLIST.md b/docs/guides/frontend/08-UI-REVIEW-CHECKLIST.md new file mode 100644 index 00000000..c27c38bc --- /dev/null +++ b/docs/guides/frontend/08-UI-REVIEW-CHECKLIST.md @@ -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 есть много “нет”, задача не считается просто “визуально нормальной”. Она должна либо быть доделана, либо иметь явный зафиксированный компромисс. diff --git a/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md b/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md new file mode 100644 index 00000000..76e62540 --- /dev/null +++ b/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md @@ -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`, новый код внутри неё особенно не должен копировать старые решения “как есть”. diff --git a/docs/guides/frontend/README.md b/docs/guides/frontend/README.md new file mode 100644 index 00000000..8869560f --- /dev/null +++ b/docs/guides/frontend/README.md @@ -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`. diff --git a/frontend/src/app/requests/page.tsx b/frontend/src/app/requests/page.tsx index 12242a4b..c1c2af91 100644 --- a/frontend/src/app/requests/page.tsx +++ b/frontend/src/app/requests/page.tsx @@ -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(null); const requestMenuRef = useRef(null); const openedLinkedRequestIdRef = useRef(null); const loadingLinkedRequestIdRef = useRef(null); const [requestMenuOpenId, setRequestMenuOpenId] = useState(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 ? ( - {personName} - ) : ( - - {(person.first_name?.[0] || person.last_name?.[0] || "?").toUpperCase()} - - )} - {personName} - - ); - 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 - ? {chip} - : {chip}; - }; - 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() {

{h.error}

) : (
- {h.swipeMode && (canManage || canProcess) ? ( + {h.swipeMode && pendingDecisionRequests.length > 0 ? (

Быстрый разбор

{h.actionError &&

{h.actionError}

} - h.setSwipeMode(false)} /> + h.setSwipeMode(false)} />
) : ( <> -
-
-

Заявления

- {(canManage || canProcess) && ( - - )} -
- -
- - {h.actionError &&

{h.actionError}

} - {h.actionSuccess &&

{h.actionSuccess}

} - -
-
- - h.setSearch(e.target.value)} placeholder="Поиск по заявлениям" className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm" /> -
- -
- - - -
-
- - {h.filtersOpen && ( -
- - - - -
h.setCreatedFromFilter(e.target.value)} className="app-input flex-1 rounded-lg px-3 py-2 text-sm" /> h.setCreatedToFilter(e.target.value)} className="app-input flex-1 rounded-lg px-3 py-2 text-sm" />
-
h.setPeriodFromFilter(e.target.value)} className="app-input flex-1 rounded-lg px-3 py-2 text-sm" /> h.setPeriodToFilter(e.target.value)} className="app-input flex-1 rounded-lg px-3 py-2 text-sm" />
- {[h.view, h.typeFilter, h.statusFilter, h.employeeFilter, h.createdFromFilter, h.createdToFilter, h.periodFromFilter, h.periodToFilter].some(Boolean) && ( - - )} -
- )} + { + 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} + />
{h.requests.length === 0 ? (

Заявления не найдены

- ) : 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 ( -
-
-
-
- - -
-
-
-
-
- {al ? ( - - {requestAuthor?.avatar ? {authorName} : {(requestAuthor?.first_name?.[0] || requestAuthor?.last_name?.[0] || "?").toUpperCase()}} - {authorName} - - ) : ( -
?{authorName}
- )} -
- -
Период: {item.date_from ? formatDate(item.date_from) : "—"}{item.date_to ? ` — ${formatDate(item.date_to)}` : ""}
-
-
-
- {st.label} - {hasSecondaryActions ? ( -
- - {requestMenuOpenId === item.id ? ( -
- {canCancel ? ( - - ) : null} - {canEdit ? ( - - ) : null} - {canDelete ? ( - - ) : null} -
- ) : null} -
- ) : null} -
-
-
- {summary &&

{summary}

} - {canPr &&
} -
-
- - {(rowOpen || commentsOpen) && ( -
- {rowOpen && ( -
-
-
Решающий: {(() => { const a = item.approver || item.assigned_to; if (!a) return —; const alink = userProfileLink(a as User, user?.id); const aname = displayUserName(a); return alink ? {aname} : {aname}; })()}
-
Создано: {formatDate(item.created_at) || "—"}
-
Обновлено: {formatDate(item.updated_at) || "—"}
- {deptLabels &&
Отделы: {deptLabels}
} -
-
-
Получатели:
{recs.slice(0,2).map((r) =>
{renderUserBadge(r)}
)}{recs.length === 0 && {item.recipient_count ?? 0}}{recs.length > 2 && +{recs.length - 2}}
-
В копии:
{ccs.slice(0,2).map((c) =>
{renderUserBadge(c)}
)}{ccs.length === 0 && —}{ccs.length > 2 && +{ccs.length - 2}}
- {(item.attachment || item.attachment_url) &&
} -
-
- )} - {commentsOpen && ( -
-
{comments.length === 0 ?

Комментариев пока нет

: comments.map((c) => (
{displayUserName(c.author)}
{formatDate(c.created_at)}{Boolean(c.author?.id && (user?.id === c.author.id || auth?.is_staff || auth?.is_superuser)) && }

{c.text}

))}
-
h.setCommentDrafts((p) => ({ ...p, [item.id]: e.target.value }))} placeholder="Добавить комментарий" className="app-input flex-1 rounded-lg px-3 py-2 text-xs" />
-
- )} -
- )} -
-
- ); - })} + ) : h.requests.map((item) => ( + h.setCommentDrafts((prev) => ({ ...prev, [requestId]: value }))} + onToggleComments={h.toggleComments} + onToggleMenu={setRequestMenuOpenId} + onToggleRow={h.toggleRow} + request={item} + rowOpen={Boolean(h.expandedRows[item.id])} + /> + ))}
{h.nextPage &&
{h.loadingMore &&
Загрузка...
}
} @@ -407,61 +246,34 @@ function RequestsPageContent() {
)} - {/* Detail modal */} - - {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 ( -
-
{dtl}{ds.label}

{dr.display_title || dr.title || "Без заголовка"}

{(dr.comment || dr.description) ?
{dr.comment || dr.description}
:

Описание отсутствует

}
-
-

Даты

Период

{dr.date_from ? formatDate(dr.date_from) : "—"}{dr.date_to ? ` — ${formatDate(dr.date_to)}` : ""}

Создано

{formatDateTime(dr.created_at) || "—"}

Обновлено

{formatDateTime(dr.updated_at) || "—"}

-

Участники

Автор

{da ? renderUserBadge(da as User, true) : —}

Решающий

{dap ? renderUserBadge(dap as User, true) : —}
-
-

Отделы

{(dr.departments || []).length > 0 ?
{(dr.departments || []).map((id) => {h.departmentNameMap.get(Number(id)) || `Отдел #${id}`})}
:

Отделы не указаны

}
-

Получатели

{(dr.recipients || []).length > 0 ?
{(dr.recipients || []).map((r) =>
{renderUserBadge(r, true)}
)}
:

Получатели не указаны

}
-

В копии

{(dr.cc_users || []).length > 0 ?
{(dr.cc_users || []).map((c) =>
{renderUserBadge(c, true)}
)}
:

Копия не указана

}
-

Вложение

{dau ?
Открыть в новой вкладке
:

Вложение отсутствует

}
-
- ); - })()} -
+ - {/* Create/Edit modal */} - - - - - - }> - {h.actionError &&

{h.actionError}

} -
-
h.setForm((p) => ({ ...p, title: e.target.value }))} placeholder="Тема заявления" className="app-input w-full rounded-lg px-3 py-2 text-sm" />
-
-
h.setForm((p) => ({ ...p, date_from: e.target.value }))} className="app-input flex-1 rounded-lg px-3 py-2 text-sm" />— h.setForm((p) => ({ ...p, date_to: e.target.value }))} className="app-input flex-1 rounded-lg px-3 py-2 text-sm" />
- !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] }))} /> - !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] }))} /> - ({ 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] }))} /> -