diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000..174c38f
Binary files /dev/null and b/.DS_Store differ
diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml
index 2380d81..088f270 100644
--- a/.gitea/workflows/build.yml
+++ b/.gitea/workflows/build.yml
@@ -17,11 +17,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - name: Validate site files
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '24'
+ - name: Build VitePress site
run: |
- test -f site/index.html
- test -f site/README.md
- test -f site/docs/test-contour-article.md
+ npm ci
+ npm run build
+ test -f .vitepress/dist/index.html
+ test -f infrastructure/test-contour/test-contour-article.md
parse-tag:
if: startsWith(github.ref, 'refs/tags/docs-v')
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c76bc00
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+.vitepress/cache/
+.vitepress/dist/
diff --git a/.vitepress/config.mts b/.vitepress/config.mts
new file mode 100644
index 0000000..4180bec
--- /dev/null
+++ b/.vitepress/config.mts
@@ -0,0 +1,113 @@
+import { defineConfig } from 'vitepress'
+
+const escapeHtml = (value: string) =>
+ value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+
+export default defineConfig({
+ lang: 'ru-RU',
+ title: 'Sova: документация проекта',
+ description: 'Онбординг, запуск и эксплуатация инфраструктуры Sova',
+ cleanUrls: true,
+ ignoreDeadLinks: true,
+ markdown: {
+ config(md) {
+ const defaultFence = md.renderer.rules.fence
+
+ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
+ const token = tokens[idx]
+ const info = token.info.trim().split(/\s+/)[0]
+
+ if (info === 'mermaid') {
+ return `
${escapeHtml(token.content)}`
+ }
+
+ return defaultFence
+ ? defaultFence(tokens, idx, options, env, self)
+ : self.renderToken(tokens, idx, options)
+ }
+ }
+ },
+ themeConfig: {
+ nav: [
+ { text: 'Старт', link: '/quick-start' },
+ { text: 'Архитектура', link: '/architecture' },
+ { text: 'Test-контур k3s', link: '/infrastructure/test-contour/' },
+ { text: 'Инфраструктура', link: '/infrastructure/docker' }
+ ],
+ sidebar: [
+ {
+ text: 'Онбординг',
+ items: [
+ { text: 'Обзор', link: '/' },
+ { text: 'Быстрый старт', link: '/quick-start' },
+ { text: 'Проверка локального контура', link: '/testing' },
+ { text: 'Окружение', link: '/environment' },
+ { text: 'Архитектура', link: '/architecture' },
+ { text: 'Потоки данных', link: '/flows' },
+ { text: 'API и Swagger', link: '/api-routes' },
+ { text: 'Модели данных', link: '/data-model' },
+ { text: 'Документация VitePress', link: '/docs-site' }
+ ]
+ },
+ {
+ text: 'Приложения',
+ items: [
+ { text: 'Backend API', link: '/apps/backend' },
+ { text: 'Backend: DDD / бизнес-сущности', link: '/apps/backend-ddd' },
+ {
+ text: 'Backend: бизнес-сценарии (use cases)',
+ collapsed: true,
+ items: [
+ { text: 'Оглавление', link: '/apps/backend-scenarios/' },
+ { text: '1.1 Логин и JWT', link: '/apps/backend-scenarios/login-jwt' },
+ { text: '1.2 UID / pcode', link: '/apps/backend-scenarios/auth-uid-pcode' },
+ { text: '1.3 Смена региона', link: '/apps/backend-scenarios/change-region' },
+ { text: '2.1 Карточка врача и локации', link: '/apps/backend-scenarios/specialist-card-locations' },
+ { text: '2.2 Расписание и кеш', link: '/apps/backend-scenarios/schedule-cache' },
+ { text: '2.3 GetScheduleMessage', link: '/apps/backend-scenarios/schedule-messenger' },
+ { text: '2.4 Расписание: полный мануал (Backend + Cabinet)', link: '/apps/doctor-schedule-sync' },
+ { text: '3.1 Анонимная запись', link: '/apps/backend-scenarios/anonymous-reserve' },
+ { text: '3.2 SMS и Record', link: '/apps/backend-scenarios/sms-record' },
+ { text: '3.3 Киоск checkpass', link: '/apps/backend-scenarios/kiosk-checkpass' },
+ { text: '4.1 Синхронизация врачей/отзывов', link: '/apps/backend-scenarios/sync-doctors-reviews' },
+ { text: '4.2 Calltouch', link: '/apps/backend-scenarios/calltouch-lead' },
+ { text: '4.3 XML-фид Яндекса', link: '/apps/backend-scenarios/xml-yandex-feed' }
+ ]
+ },
+ { text: 'Backend: CRUD контента', link: '/apps/backend-content-crud' },
+ { text: 'adminPanel: обзор', link: '/apps/admin-panel' },
+ { text: 'adminPanel: CRUD контента', link: '/apps/admin-panel-content-crud' },
+ { text: 'Backend: архитектура модулей', link: '/apps/backend-architecture' },
+ { text: 'Cabinet', link: '/apps/cabinet' },
+ { text: 'Cabinet: онлайн-консультация', link: '/apps/online-consultation' },
+ { text: 'Cabinet: архитектура модулей', link: '/apps/cabinet-architecture' }
+ ]
+ },
+ {
+ text: 'Инфраструктура',
+ items: [
+ { text: 'Docker Compose', link: '/infrastructure/docker' },
+ { text: 'K8s + Terraform + ArgoCD + Gitea', link: '/infrastructure/k8s-cicd-platform-plan' },
+ { text: 'Backend: внешние сервисы (test/stage/prod)', link: '/infrastructure/backend-external-services' },
+ {
+ text: 'Test-контур k3s (песочница)',
+ collapsed: false,
+ items: [
+ { text: 'Обзор', link: '/infrastructure/test-contour/' },
+ { text: 'Что сделано + перенос на сервер', link: '/infrastructure/test-contour/test-contour-article' },
+ { text: 'Система тегов CI/CD', link: '/infrastructure/test-contour/tags' },
+ { text: 'ArgoCD: sova-root и data-test', link: '/infrastructure/test-contour/argocd-apps' }
+ ]
+ },
+ { text: 'Эксплуатация', link: '/operations/maintenance' }
+ ]
+ }
+ ],
+ socialLinks: []
+ }
+})
diff --git a/.vitepress/theme/index.ts b/.vitepress/theme/index.ts
new file mode 100644
index 0000000..503c921
--- /dev/null
+++ b/.vitepress/theme/index.ts
@@ -0,0 +1,44 @@
+import DefaultTheme from 'vitepress/theme'
+import mermaid from 'mermaid'
+import { nextTick, watch } from 'vue'
+import { useRoute } from 'vitepress'
+import './style.css'
+
+const renderMermaid = async () => {
+ await nextTick()
+
+ const diagrams = Array.from(
+ document.querySelectorAll('.mermaid')
+ )
+
+ diagrams.forEach((diagram) => {
+ diagram.removeAttribute('data-processed')
+ })
+
+ await mermaid.run({ nodes: diagrams })
+}
+
+export default {
+ extends: DefaultTheme,
+ setup() {
+ if (typeof window === 'undefined') {
+ return
+ }
+
+ const route = useRoute()
+
+ mermaid.initialize({
+ startOnLoad: false,
+ securityLevel: 'loose',
+ theme: 'default'
+ })
+
+ watch(
+ () => route.path,
+ () => {
+ renderMermaid()
+ },
+ { immediate: true }
+ )
+ }
+}
diff --git a/.vitepress/theme/style.css b/.vitepress/theme/style.css
new file mode 100644
index 0000000..be53921
--- /dev/null
+++ b/.vitepress/theme/style.css
@@ -0,0 +1,15 @@
+.mermaid {
+ margin: 24px 0;
+ padding: 16px;
+ overflow-x: auto;
+ border: 1px solid var(--vp-c-divider);
+ border-radius: 12px;
+ background: var(--vp-c-bg-soft);
+ text-align: center;
+ white-space: pre;
+}
+
+.mermaid svg {
+ max-width: 100%;
+ height: auto;
+}
diff --git a/Dockerfile b/Dockerfile
index 7b96fa3..53abf67 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,13 @@
+# syntax=docker/dockerfile:1
+
+FROM node:24-alpine AS builder
+WORKDIR /docs
+COPY package.json package-lock.json ./
+RUN npm ci
+COPY . .
+RUN npm run build
+
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
-COPY site/ /usr/share/nginx/html/
+COPY --from=builder /docs/.vitepress/dist /usr/share/nginx/html
EXPOSE 8080
diff --git a/api-routes.md b/api-routes.md
new file mode 100644
index 0000000..53d5e1d
--- /dev/null
+++ b/api-routes.md
@@ -0,0 +1,209 @@
+# API, HTTP-методы и Swagger
+
+## Swagger / OpenAPI
+
+Backend API:
+
+- UI: `http://localhost:8081/docs`
+- JSON: `http://localhost:8081/api/doc.json`
+- Конфиг: `apps/backend/config/routes/nelmio_api_doc.yaml`
+- В документацию backend сейчас попадают только path patterns из `apps/backend/config/packages/nelmio_api_doc.yaml`.
+
+Cabinet:
+
+- UI: `http://localhost:8082/api/swagger`
+- JSON: `http://localhost:8082/api/swagger.json`
+- Реализовано вручную в `apps/cabinet/src/Controller/InternalAPIController.php`.
+
+## Backend API
+
+### Пользователь
+
+| Метод | Путь | Контроллер | Назначение |
+| --- | --- | --- | --- |
+| `GET` | `/user/logout` | `UserController::logout` | Logout-заглушка для клиента |
+| `POST` | `/user/login` | `UserController::login` | Логин по email/password, выдача JWT |
+| `GET` | `/user/` | `UserController::index` | Текущий пользователь по JWT |
+| `PUT` | `/user/change-region` | `UserController::changeRegion` | Смена региона пользователя |
+| `POST` | `/user/auth` | `UserController::auth` | Регистрация/авторизация |
+| `POST` | `/user/auth-by-pcode` | `UserController::authByPcode` | Авторизация по pcode и дате рождения |
+
+### Справочники и контент
+
+| Метод | Путь | Контроллер | Назначение |
+| --- | --- | --- | --- |
+| `GET` | `/filial/list` | `FilialController` | Список филиалов |
+| `GET` | `/filial/by-region/{regionId}` | `FilialController` | Филиалы по региону |
+| `PUT` | `/filial/{fid}` | `FilialController` | Обновление филиала |
+| `POST` | `/filial/create` | `FilialController` | Создание филиала |
+| `GET` | `/filial/picture/{id}` | `FilialController` | Картинка филиала |
+| `POST` | `/filial/picture/{id}` | `FilialController` | Загрузка картинки филиала |
+| `GET` | `/department/list` | `DepartmentController` | Список отделений |
+| `PUT` | `/department/{did}` | `DepartmentController` | Обновление отделения |
+| `POST` | `/department/create` | `DepartmentController` | Создание отделения |
+| `GET` | `/pricelist/list` | `PriceListController` | Прайс |
+| `GET` | `/pricelist/department` | `PriceDepartmentController` | Группы прайса |
+
+### Врачи и расписание
+
+| Метод | Путь | Контроллер | Назначение |
+| --- | --- | --- | --- |
+| `POST` | `/specialist/create` | `SpecialistController` | Создание врача |
+| `PUT` | `/specialist/{id}` | `SpecialistController` | Обновление врача |
+| `DELETE` | `/specialist/{id}` | `SpecialistController` | Удаление врача |
+| `GET` | `/specialist/list` | `SpecialistController` | Список врачей |
+| `GET` | `/specialist/post` | `SpecialistController` | Список должностей |
+| `GET` | `/specialist/picture/{id}` | `SpecialistController` | Фото врача |
+| `POST` | `/specialist/picture/{id}` | `SpecialistController` | Загрузка фото врача |
+| `GET` | `/specialist/schedule` | `SpecialistController` | Расписание врача |
+| `GET` | `/specialist/{id}` | `SpecialistController` | Детальная карточка по id |
+| `GET` | `/specialist/by/{identifier}` | `SpecialistController` | Детальная карточка по alias/id |
+| `POST` | `/specialist/{id}/location/create` | `LocationController` | Создание локации врача |
+| `PUT` | `/specialist/{specialistId}/location/{id}` | `LocationController` | Обновление локации врача |
+| `GET` | `/specialist-dcode-description/list` | `SpecialistDcodeDescriptionController` | Описания dcode |
+| `GET` | `/specialist-dcode-description/{id}` | `SpecialistDcodeDescriptionController` | Описание dcode |
+| `POST` | `/specialist/{specialistId}/specialist-dcode-description/create` | `SpecialistDcodeDescriptionController` | Создание описания dcode |
+| `PUT` | `/specialist/{specialistId}/specialist-dcode-description/{id}` | `SpecialistDcodeDescriptionController` | Обновление описания dcode |
+| `DELETE` | `/specialist-dcode-description/{id}` | `SpecialistDcodeDescriptionController` | Удаление описания dcode |
+| `GET` | `/specialist-docs/list` | `SpecialistDocsController` | Документы врачей |
+| `GET` | `/specialist-docs/{id}` | `SpecialistDocsController` | Документ врача |
+| `GET` | `/specialist-docs/picture/{id}` | `SpecialistDocsController` | Картинка документа |
+| `POST` | `/specialist/{id}/specialist-docs/create` | `SpecialistDocsController` | Создание документа |
+| `PUT` | `/specialist/{specialistId}/specialist-docs/{id}` | `SpecialistDocsController` | Обновление документа |
+| `DELETE` | `/specialist-docs/{id}` | `SpecialistDocsController` | Удаление документа |
+| `POST` | `/specialist-docs/picture/{id}` | `SpecialistDocsController` | Загрузка картинки документа |
+| `DELETE/PUT` | `/stock/{id}/specialist/{specialistId}` | `StockSpecialistController` | Связь акции и врача |
+
+### CRUD-контент
+
+Эти контроллеры имеют одинаковый набор методов:
+
+| Контроллер | Базовый путь | Методы |
+| --- | --- | --- |
+| `ArticleController` | `/article` | `GET /list`, `GET /alias/{alias}`, `GET /{id}`, `POST /create`, `PUT /{id}`, `DELETE /{id}` |
+| `DiseaseController` | `/disease` | `GET /list`, `GET /{id}`, `POST /create`, `PUT /{id}`, `DELETE /{id}` |
+| `MedicalCenterController` | `/medical-center` | `GET /list`, `GET /{id}`, `POST /create`, `PUT /{id}`, `DELETE /{id}` |
+| `NewsController` | `/news` | `GET /list`, `GET /{id}`, `POST /create`, `PUT /{id}`, `DELETE /{id}` |
+| `PromoController` | `/promo` | `GET /list`, `GET /{id}`, `POST /create`, `PUT /{id}`, `DELETE /{id}` |
+| `ReviewController` | `/review` | `GET /list`, `GET /{id}`, `POST /create`, `PUT /{id}`, `DELETE /{id}` |
+| `SiteServiceController` | `/site-services` | `GET /list`, `GET /{id}`, `POST /create`, `PUT /{id}`, `DELETE /{id}` |
+
+Для контентных сущностей `POST`, `PUT` и `DELETE` доступны только с JWT пользователя с ролью `ROLE_ADMIN`.
+
+Списки `news`, `promo`, `medical-center`, `disease`, `site-services` возвращают единый формат пагинации:
+
+```json
+{
+ "data": [],
+ "pagination": {
+ "total": 1,
+ "count": 1,
+ "per_page": 50,
+ "current_page": 1,
+ "total_pages": 1,
+ "has_previous_page": false,
+ "has_next_page": false
+ }
+}
+```
+
+Основные query-параметры списков:
+
+- `page` - номер страницы;
+- `perPage` - размер страницы;
+- `regionId` - фильтр по региону;
+- `active` - фильтр активности.
+
+`/article/list` сохраняет старый контракт фронтенда: размер страницы задаётся параметром `limit`, а метаданные возвращаются в ключе `meta`:
+
+```json
+{
+ "data": [],
+ "meta": {
+ "total": 1,
+ "page": 1,
+ "limit": 20,
+ "totalPages": 1
+ }
+}
+```
+
+Пример создания новости:
+
+```bash
+curl -X POST http://localhost:8081/news/create \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ --data '{
+ "id": 900001,
+ "name": "Локальная новость",
+ "active": true,
+ "regionId": 91,
+ "alias": "local-admin-news",
+ "anons": "Краткий анонс",
+ "content": "Полный текст новости"
+ }'
+```
+
+### Интеграции и служебные методы
+
+| Метод | Путь | Контроллер | Назначение |
+| --- | --- | --- | --- |
+| `GET` | `/infoclinica/clvisitsovacheckpass/{filial}` | `InfoclinicaController` | Проверка прохода |
+| `GET` | `/idoctor/list` | `InfoclinicaController` | Список врачей Infoclinica |
+| `POST` | `/reservation/anonymous-reserve` | `InfoclinicaController` | Анонимная запись |
+| `POST` | `/calltouch/create-lead` | `CalltouchController` | Создание лида |
+| `GET` | `/service/sendmail` | `ServiceController` | Отправка письма |
+| `POST` | `/smart-captcha` | `ServiceController` | Проверка captcha |
+| `GET` | `/xml/feed` | `XmlFeedController` | XML-фид |
+| `GET` | `/xml/feed/v1` | `XmlFeedController` | XML-фид v1 |
+
+## Cabinet API и страницы
+
+### Swagger и внутренний API
+
+| Метод | Путь | Контроллер | Назначение |
+| --- | --- | --- | --- |
+| `ANY` | `/api/swagger.json` | `InternalAPIController::swaggerJson` | OpenAPI JSON |
+| `ANY` | `/api/swagger` | `InternalAPIController::swaggerUI` | Swagger UI |
+| `POST` | `/api/smart-captcha` | `InternalAPIController::smartCaptcha` | Проверка captcha |
+| `GET` | `/api/banner/{regionId}` | `InternalAPIController::show` | Баннер региона |
+| `POST` | `/api/log` | `InternalAPIController::log` | Логирование |
+| `POST` | `/api/count-record` | `InternalAPIController::countRecord` | Количество записей |
+| `POST` | `/api/add-record` | `InternalAPIController` | Создание записи |
+| `POST` | `/api/msg` | `InternalAPIController` | Сообщение/SMS |
+| `POST` | `/api/veretify` | `InternalAPIController` | Проверка SMS-кода |
+| `POST` | `/api/search` | `InternalAPIController` | Поиск |
+| `GET` | `/api/departments` | `InternalAPIController` | Отделения |
+| `POST` | `/api/add-calltouch` | `CalltouchAPIController` | Заявка Calltouch |
+
+### Публичный API cabinet
+
+| Метод | Путь | Контроллер | Назначение |
+| --- | --- | --- | --- |
+| `POST` | `/api/anonymous-reserve` | `PublicAPIController` | Анонимная запись |
+| `GET` | `/api/interval` | `PublicAPIController` | Интервалы расписания |
+| `GET` | `/api/userInfo` | `PublicAPIController` | Информация о пользователе |
+| `GET` | `/api/pricelist/departments` | `PublicAPIController` | Группы прайса |
+| `GET` | `/api/pricelist` | `PublicAPIController` | Прайс |
+| `GET` | `/api/doctor` | `PublicAPIController` | Врач |
+| `GET` | `/api/doctors/{region}` | `PublicAPIController` | Врачи региона |
+
+### Страницы cabinet
+
+| Метод | Путь | Контроллер | Назначение |
+| --- | --- | --- | --- |
+| `ANY` | `/login` | `SecurityController` | Страница входа |
+| `ANY` | `/logout` | `SecurityController` | Выход |
+| `POST` | `/api/usrlog/logout` | `SecurityController` | API logout |
+| `GET/POST` | `/registration` | `SecurityController` | Регистрация |
+| `POST` | `/forget` | `SecurityController` | Восстановление |
+| `POST` | `/api/authenticated` | `SecurityController` | Авторизация API |
+| `GET` | `/` | `DefaultController` | Главная кабинета, требует авторизации |
+| `ANY` | `/stoimost-uslug` | `DefaultController` | Стоимость услуг |
+| `POST` | `/update/price-list` | `DefaultController` | Обновление прайса |
+| `ANY` | `/price-list` | `DefaultController` | Админский прайс |
+| `GET` | `/specialists/{alias?}` | `SpecialistController` | Каталог врачей |
+| `GET` | `/online-specialists` | `SpecialistController` | Онлайн-врачи |
+| `GET` | `/specialist/{alias}` | `SpecialistController` | Карточка врача |
+| `ANY` | `/favorites` | `SpecialistController` | Избранное |
diff --git a/apps/admin-panel-content-crud.md b/apps/admin-panel-content-crud.md
new file mode 100644
index 0000000..88fe7ef
--- /dev/null
+++ b/apps/admin-panel-content-crud.md
@@ -0,0 +1,416 @@
+# 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` | `