# 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` | Текст ошибки под полем: `<span class="content-field-error-msg">` |
| `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` | `<input type="text">` | string | string или `null` если пусто |
| `number` | `<input type="number">` | string в форме | `Number` или `null` |
| `checkbox` | `<input type="checkbox">` | boolean | boolean |
| `region` | `<select>` из `selectRegions` | string id | `Number(regionId)` или `null` |
| `html` | `TextEditor` | string HTML | string; props **`content`**, **`setContent`** |
| `json` | `<textarea class="font-monospace">` | string (pretty JSON) | `JSON.parse` → object/array или `null` |

При ошибке у поля:

- обёртка `content-field--has-error` (красная рамка, фон `#fff5f5`);
- label `content-field-error-label`;
- control с Bootstrap `is-invalid`;
- подпись `FieldHint`.

Стили в `theme-override.scss` (SB Admin перебивает обычный `.text-danger` на label).

### Сериализация формы

| Функция | Когда | Что делает |
| --- | --- | --- |
| `emptyFormFromConfig(fields)` | mount / create | Пустой объект формы |
| `itemToForm(item, fields)` | после `GET` detail | API → строки формы, JSON → formatted string |
| `formToPayload(form, fields)` | перед save | форма → тело PUT/POST; невалидный JSON → throw с `fieldKey` |

## Страницы

### `ContentListPage`

Пропсы: `{ config, hooks }`.

| Блок UI | Поведение |
| --- | --- |
| Заголовок | `config.title` |
| «Добавить» | `navigate(\`/${config.slug}/create\`)` |
| Поиск | `searchValue`, сброс страницы на 1 |
| Таблица | Колонки из `config.listColumns`, клик по строке → `expandedId` |
| Раскрытая строка | Кнопка «Редактировать» → `/${slug}/edit/${id}` |
| Пагинация | Серверная, `pagination` / `meta` |
| Загрузка / ошибка | `LoadingComponent` / `ErrorComponent` |

Хуки: `hooks.useListQuery({ search, page, perPage: 20 })`, `useOutsideClick` для сброса раскрытия.

### `ContentEditPage`

Пропсы: `{ config, hooks, isCreate }`.

| Режим | Загрузка данных | `id` |
| --- | --- | --- |
| create | не грузит detail | нет |
| edit | `hooks.useItemQuery(id)` | из `useParams()` |

Состояние:

| state | Назначение |
| --- | --- |
| `form` | все поля кроме отдельной логики |
| `fieldErrors` | `Record<fieldKey, message>` |
| `globalError` | строка для блока вверху формы |
| `isModalSuccess` | модалка успеха |

Оболочка: `EditElementForm` (кнопки Сохранить / Отмена / Удалить).

#### Валидация и ошибки (без alert на save)

1. **Клиент** (`validateClient`): если в `config.fields` есть `name` / `alias` / `regionId` — проверка на пустое; результат в `fieldErrors`, **без** `window.alert`.
2. **JSON** в `formToPayload`: catch → `fieldErrors[fieldKey]` или `globalError`.
3. **API** (`parseSaveError`): Symfony `violations` → ключи полей; иначе `globalError`.

Вверху формы при ошибках:

- `alert alert-danger` с `globalMessage`;
- список полей с ошибками (дублирует подсказки у полей, удобно для длинных форм).

При появлении ошибок — smooth scroll к форме и к первому `[data-field-key]`.

Успех: `Modal` «Изменения внесены», таймер 2 с, затем refetch (edit) или переход на edit (create).

Удаление: `window.confirm` (единственный системный диалог); ошибка удаления — через `parseSaveError` / `globalError`, не alert.

### `pages/content/index.jsx`

Фабрика `bind(resourceKey)`:

```javascript
ListPage: () => <ContentListPage config={config} hooks={hooks} />
EditPage: () => <ContentEditPage config={config} hooks={hooks} isCreate={false} />
CreatePage: () => <ContentEditPage config={config} hooks={hooks} isCreate />
```

Экспортирует именованные компоненты для `App.jsx`: `NewsListPage`, `NewsEditPage`, `NewsCreatePage`, …

## Слой API (`apiContent.js`)

Для каждого ключа `CONTENT_RESOURCES` вызывается `injectResource(resourceKey)`:

| Endpoint name | Hook | Метод |
| --- | --- | --- |
| `get{Resource}List` | `useGetNewsListQuery`, … | GET list |
| `get{Resource}Item` | `useGetNewsItemQuery`, … | GET one |
| `create{Resource}` | `useCreateNewsMutation`, … | POST |
| `update{Resource}` | `useUpdateNewsMutation`, … | PUT |
| `delete{Resource}` | `useDeleteNewsMutation`, … | DELETE |

Имя ресурса в PascalCase: `news` → `News`, `medical-center` → `MedicalCenter`, `site-services` → `SiteServices`.

Публичный объект:

```javascript
export const contentHooks = {
  news: { useListQuery, useItemQuery, useCreateMutation, useUpdateMutation, useDeleteMutation },
  // promo, disease, medical-center, article, site-services
}
```

`refetchOnMountOrArgChange: true`, `keepUnusedDataFor: 0` на list — как у акций.

## Переиспользуемые компоненты (не контент-специфичные)

| Компонент | Роль в CRUD |
| --- | --- |
| `EditElementForm` | Карточка: заголовок, children, Сохранить / Отмена / Удалить |
| `LoadingComponent` | Загрузка list/detail |
| `ErrorComponent` | Ошибка запроса |
| `NotFindElement` | Запись не найдена (edit) |
| `TextEditor` | HTML-поля `anons`, `content` |
| `Modal` | Успешное сохранение |
| `useOutsideClick` | Закрытие раскрытой строки в таблице |

## Навигация

Пункты в `Sidebar.jsx` и `Navbar.jsx` (порядок может отличаться):

| label | `to` |
| --- | --- |
| Новости | `/news` |
| Промо (контент) | `/site-promo` |
| Заболевания | `/disease` |
| Медцентры | `/medical-center` |
| Статьи | `/article` |
| Услуги сайта | `/site-services` |

Все маршруты — дочерние к `MainPage` + `ProtectedRoute` (нужен token в `localStorage`).

## Поля по ресурсам (кратко)

Помимо `baseContentFields`, в конфиге заданы доп. поля:

| Ресурс | Особенности списка | Доп. поля (типы) |
| --- | --- | --- |
| news | + region | text: shortName, linkElPrice, timer, timerBg; json: formOrder, linkServices, linkStaff, photos |
| promo | + region | text: shortName, period, timer, timerBg; json: clinics, linkServices, linkStaff, photos |
| disease | без region в таблице | text + checkbox hidePicture; json: tags, staffList, linkFaq, … |
| medical-center | без region | много text + json (doctors, services, plusList, …) |
| article | `listUsesMeta` + `listUsesLimit` | text previewPicture; json doctors, services |
| site-services | без region | много text + json (faq, quiz, photos, …) |

Полный список — в `src/config/contentResources.js`.

## `parseSaveError`

Файл: `src/utils/parseSaveError.js`.

Обрабатывает:

- клиентский throw с `fieldKey` (JSON);
- RTK Query `err.data` — массив Symfony violations или объект с `violations`;
- строковый JSON в `data`;
- десериализацию Symfony (`propertyPath`, `message`);
- уникальные constraint (duplicate key) → привязка к `name` / `alias` по тексту.

Возвращает `{ fieldErrors, globalMessage }`. Если есть полевые ошибки, `globalMessage` обычно `null`.

## Как добавить седьмой ресурс

1. Backend: architecture + resource-ветка ([инструкция](/apps/backend-content-crud#как-добавить-новый-ресурс-по-образцу-news)).
2. В `CONTENT_RESOURCES` добавить объект с `slug`, `basePath`, `listColumns`, `fields`.
3. Ключ объекта автоматически подхватится в `apiContent.js` (`injectResource`) и в `index.jsx` (`bind`).
4. В `App.jsx` — три `Route` (list, create, edit/:id).
5. В `Sidebar` / `Navbar` — ссылка `to: '/your-slug'`.

Отдельные JSX-страницы для ресурса **не нужны**.

## Локальный запуск

```bash
make local-up
```

| Сервис | URL |
| --- | --- |
| Админка | http://localhost:3211/login |
| Backend API | http://localhost:8081 |

Логин: `local.backend@example.test` / `local-password` (`ROLE_ADMIN`).

`apps/adminPanel/.env.local`:

```env
VITE_API_BASE_URL=http://localhost:8081
```

Backend должен быть с поднятым content CRUD (`issues/27` или feature-ветки).

## Проверка

1. Войти под админом.
2. Для каждого из 6 пунктов меню: список, поиск, пагинация, create, edit, delete.
3. Сохранить с пустым `name` — красная подсветка, **без** alert.
4. Невалидный JSON в textarea — ошибка на поле.
5. Успешное сохранение — модалка, без alert.
6. Сравнить с backend: те же поля уходят в PUT/POST, что в [serializer groups](/apps/backend-content-crud).

## Сравнение с `/promotions`

| | Контент (`issues/27-future`) | Акции (`/promotions`) |
| --- | --- | --- |
| Страницы | 2 общие + конфиг | 3 отдельных файла |
| API | 1 `apiContent.js` | 1 `apiStock.js` |
| Поля формы | `ContentField` по типу | JSX вручную в `EditStockPage` |
| Ошибки save | `parseSaveError` + красные блоки | `text-danger` + `window.alert` |
| Успех | `Modal` | `Modal` |
| Когда выбирать | Много однотипных сущностей | Один сложный домен с датами и картинкой |
