# adminPanel: CRUD контентных сущностей Подробное описание UI для [backend CRUD контента](/apps/backend-content-crud). Общие соглашения админки (layout, RTK, переиспользуемые компоненты): [adminPanel: обзор](/apps/admin-panel). ## Задача (#27) Дать контент-менеджерам экраны в `apps/adminPanel` для шести сущностей backend API: | Сущность | Раздел меню | Backend API | | --- | --- | --- | | Новости | Новости | `/news` | | Промо (контент) | Промо (контент) | `/promo` | | Заболевания | Заболевания | `/disease` | | Медцентры | Медцентры | `/medical-center` | | Статьи | Статьи | `/article` | | Услуги сайта | Услуги сайта | `/site-services` | **Не путать** с разделом **«Акции»** (`/promotions`) — это сущность `stock` (отдельные `apiStock.js`, `StoksListPage`, `EditStockPage`). Требования к UI: - единый CRUD-подход для всех шести ресурсов (один код списка и формы, конфиг полей); - список с поиском и серверной пагинацией; - создание / редактирование / удаление; - ошибки валидации **на форме** (красная подсветка полей), **без** `window.alert` при сохранении; - успешное сохранение — `Modal`, как у акций. ## Ветки Git | Ветка | Подход | Назначение | | --- | --- | --- | | **`issues/27-future`** | Generic: `ContentListPage`, `ContentEditPage`, `apiContent` | Рекомендуемая реализация с виджетами полей и `parseSaveError` | | `issues/27` | Копия `/promotions`: отдельные `*ListPage` / `Edit*Page` / `api*` на ресурс | Интеграционная / альтернативная ветка | | `issues/27-refactor` | То же, что `issues/27` (от `dev`) | Исторический MR «как акции» | | `dev` | Без контентного CRUD | База | Backend: [ветки и MR](/apps/backend-content-crud#ветки-git-и-mr-файлы) — `issues/27` на backend **не трогать**. Дальше в документе описан код ветки **`issues/27-future`**. ## Ресурсы: UI-маршруты и API У каждого ресурса **три маршрута** в React Router и один `basePath` в RTK Query. | Ключ конфига | `slug` (UI) | Список | Создание | Редактирование | `basePath` (API) | | --- | --- | --- | --- | --- | --- | | `news` | `/news` | `/news` | `/news/create` | `/news/edit/:id` | `/news` | | `promo` | `/site-promo` | `/site-promo` | `/site-promo/create` | `/site-promo/edit/:id` | `/promo` | | `disease` | `/disease` | `/disease` | `/disease/create` | `/disease/edit/:id` | `/disease` | | `medical-center` | `/medical-center` | `/medical-center` | `/medical-center/create` | `/medical-center/edit/:id` | `/medical-center` | | `article` | `/article` | `/article` | `/article/create` | `/article/edit/:id` | `/article` | | `site-services` | `/site-services` | `/site-services` | `/site-services/create` | `/site-services/edit/:id` | `/site-services` | `slug` — сегмент в URL админки. `basePath` — префикс HTTP к backend (может отличаться, напр. промо: UI `site-promo`, API `promo`). ### Соответствие HTTP backend | Действие в UI | RTK hook | HTTP | | --- | --- | --- | | Список | `useListQuery({ search, page, perPage })` | `GET {basePath}/list?...` | | Карточка | `useItemQuery(id)` | `GET {basePath}/{id}` | | Создание | `useCreateMutation(data)` | `POST {basePath}/create` | | Обновление | `useUpdateMutation({ id, data })` | `PUT {basePath}/{id}` | | Удаление | `useDeleteMutation(id)` | `DELETE {basePath}/{id}` | Write-запросы отправляют `Authorization: Bearer` через `authHeader()` в `apiContent.js`. Пагинация списка: - по умолчанию: `?page=1&perPage=20&search=...`; - **статьи**: `listUsesLimit: true` → `limit` вместо `perPage`; - ответ: `{ data, pagination }` или для статей `{ data, meta }` — UI нормализует в `normalizePagination()`. ## Общая схема ```mermaid flowchart TD subgraph routes [React Router] List["/{slug}"] Create["/{slug}/create"] Edit["/{slug}/edit/:id"] end subgraph pages [pages/content] Index["index.jsx → NewsListPage, …"] CLP[ContentListPage] CEP[ContentEditPage] end subgraph config [Конфигурация] CR[contentResources.js] Hooks[contentHooks в apiContent.js] end List --> Index --> CLP Create --> Index --> CEP Edit --> Index --> CEP CLP --> Hooks CEP --> Hooks CLP --> CR CEP --> CR Hooks --> Slice[apiSlice + baseUrl] Slice --> API[Backend Symfony CRUD] CEP --> Parse[parseSaveError] CEP --> Widgets[ContentField / FieldHint] CEP --> Shell[EditElementForm + Modal] ``` ## Ключевой принцип Страница **не знает**, сколько полей у «новости» или «услуги». Она получает `config` и `hooks` и рисует форму по `config.fields` и таблицу по `config.listColumns`. Добавление седьмого ресурса = запись в `CONTENT_RESOURCES` + автоматически те же `ContentListPage` / `ContentEditPage` (через `pages/content/index.jsx`). Контраст с `/promotions`: там отдельный файл страницы и `apiStock.js` на домен. Здесь — **один** список и **одна** форма на все ресурсы. ## Файлы проекта | Файл | Назначение | | --- | --- | | `src/config/contentResources.js` | Описание 6 ресурсов: `slug`, `basePath`, колонки списка, поля формы | | `src/api/apiContent.js` | RTK Query: `injectEndpoints` × 6, экспорт `contentHooks` | | `src/pages/content/ContentListPage.jsx` | Универсальная страница списка | | `src/pages/content/ContentEditPage.jsx` | Универсальная форма create/edit + виджеты полей | | `src/pages/content/index.jsx` | Связывает ресурс → `NewsListPage`, `NewsEditPage`, … | | `src/utils/parseSaveError.js` | Разбор ошибок API → `fieldErrors` / `globalMessage` | | `src/styles/theme-override.scss` | Классы `.content-field--has-error`, `.content-field-error-msg` | | `src/App.jsx` | 18 маршрутов (6 × list/create/edit) | | `src/components/Sidebar/Sidebar.jsx` | Пункты меню | | `src/components/Navbar/Navbar.jsx` | Мобильное меню | | `src/store/store.js` | `import '../api/apiContent'` | | `src/config/api.js` | `VITE_API_BASE_URL` (локальная разработка) | ## Конфигурация ресурса (`contentResources.js`) Каждый ресурс — объект в `CONTENT_RESOURCES`: | Поле | Тип | Назначение | | --- | --- | --- | | `slug` | string | URL в админке (`news`, `site-promo`, …) | | `basePath` | string | Префикс API (`/news`, `/promo`, …) | | `title` | string | Заголовок списка | | `titleSingle` | string | Подпись в форме («новость», «услугу») | | `icon` | string | Font Awesome для меню (не используется в `ContentListPage`, только в Sidebar) | | `listColumns` | array | Колонки таблицы списка | | `fields` | array | Поля формы create/edit | | `listUsesLimit` | boolean? | Только `article`: `limit` в query | | `listUsesMeta` | boolean? | Только `article`: ответ с `meta` | ### Колонки списка (`listColumns`) ```javascript { key: 'id', label: 'ID' } { key: 'name', label: 'Название' } { key: 'active', label: 'Активно', format: 'bool' } // → «Да» / «Нет» { key: 'regionId', label: 'Регион' } // → имя из regionSlice ``` ### Поля формы (`fields`) Общий набор для большинства ресурсов (`baseContentFields`): | `key` | `type` | Виджет | | --- | --- | --- | | `name` | `text` | input text | | `active` | `checkbox` | checkbox | | `regionId` | `region` | select регионов | | `alias` | `text` | input text | | `anons` | `html` | `TextEditor` | | `content` | `html` | `TextEditor` | Дополнительные поля задаются хелперами `text(key, label)` и `json(key, label)` — см. таблицу ресурсов ниже. ## Виджеты полей (форма) Виджеты объявлены **внутри** `ContentEditPage.jsx` (не отдельные файлы в `components/`). Это осознанный «мини-движок формы» для контента. ### Вспомогательные компоненты | Виджет | Назначение | | --- | --- | | `FieldHint` | Текст ошибки под полем: `` | | `fieldWrapperClass(hasError, extra)` | Собирает классы: `form-group`, `content-field--has-error`, `form-check` | | `ContentField` | Рендер одного поля по `field.type` | Атрибут `data-field-key={field.key}` на обёртке — для скролла к первому полю с ошибкой (`querySelector`). ### Типы полей (`field.type`) | type | UI | Значение в state | Отправка в API | | --- | --- | --- | --- | | `text` | `` | string | string или `null` если пусто | | `number` | `` | string в форме | `Number` или `null` | | `checkbox` | `` | boolean | boolean | | `region` | `