diff --git a/docs/info/backend-issues-competence-map-analytics-current-data.md b/docs/info/backend-issues-competence-map-analytics-current-data.md new file mode 100644 index 0000000..8c4a978 --- /dev/null +++ b/docs/info/backend-issues-competence-map-analytics-current-data.md @@ -0,0 +1,455 @@ +# Backend Issues Competence Map Analytics Current Data + +Назначение: список проблем текущей реализации backend-данных для страницы +`Все сервисы / Карта компетенций / Аналитическая панель`. + +Документ нужен как задача для backend-разработки. +Цель: привести фактический response тестового backend-а к контракту из +`docs/info/backend-endpoints-competence-map-analytics-from-layouts.md` и `openapi.json`. + +Тестовый endpoint: + +```text +GET https://anal-back.dev.nii-ecos.ru/api/v1/competence-map/analytics/ +``` + +Проблемы ниже относятся к page-level endpoint-у: + +```text +GET /api/v1/competence-map/analytics/?association_type=all +GET /api/v1/competence-map/analytics/?association_type=sez +GET /api/v1/competence-map/analytics/?association_type=technoparks +GET /api/v1/competence-map/analytics/?association_type=clusters +``` + +Новые endpoint-ы для этих исправлений не нужны. Исправлять нужно наполнение существующего response. + +--- + +## Общие требования к исправлению + +- backend должен возвращать реальные бизнес-названия сущностей, а не технические placeholder-строки; +- frontend не должен подменять `Субъект РФ 11`, `Отрасль 24` и подобные значения локальными маппингами; +- если сущность имеет `id`, backend должен резолвить человекочитаемое имя на сервере; +- если данные по применимому блоку есть в источниках, backend должен вернуть строки блока, а не пустой массив; +- если показатель применим для выбранного `association_type`, backend должен вернуть его в контрактном поле; +- `0` является валидным числом и должен возвращаться как `0`, а не как `null`, пустая строка или отсутствие поля; +- пустой массив допустим только когда данных действительно нет после применения фильтров; +- `null` допустим только когда блок или показатель неприменим к выбранному состоянию страницы. + +--- + +## Проблема 1. Placeholder-названия субъектов РФ и отраслей + +### Где видно + +На странице в блоках рейтинга и топа отраслей отображаются значения вида: + +```text +Субъект РФ 11 +Субъект РФ 59 +Субъект РФ 65 +Отрасль 24 +Отрасль 127 +Отрасль 31 +``` + +Это не реальные названия субъектов РФ и отраслей. Такие значения нельзя отдавать в production response. + +### Затронутые поля + +`region_ranking.rows[]`: + +```json +{ + "rank": 1, + "region_id": "11", + "region_name": "Субъект РФ 11" +} +``` + +Должно быть: + +```json +{ + "rank": 1, + "region_id": "11", + "region_name": "<реальное название субъекта РФ из справочника>" +} +``` + +`top_industries.rows[]`: + +```json +{ + "industry_id": "24", + "industry_name": "Отрасль 24" +} +``` + +Должно быть: + +```json +{ + "industry_id": "24", + "industry_name": "<реальное название отрасли из справочника>" +} +``` + +### Требования + +Для `region_ranking.rows[]`: + +- `region_id` должен оставаться стабильным backend-id субъекта РФ; +- `region_name` должен быть реальным названием субъекта РФ; +- `region_name` не должен генерироваться как `Субъект РФ ${id}`; +- если субъект не найден в справочнике, это ошибка данных, которую нужно логировать на backend-е; +- frontend должен получать уже готовое отображаемое имя. + +Для `top_industries.rows[]`: + +- `industry_id` должен оставаться стабильным backend-id отрасли; +- `industry_name` должен быть реальным названием отрасли; +- `industry_name` не должен генерироваться как `Отрасль ${id}`; +- если отрасль не найдена в справочнике, это ошибка данных, которую нужно логировать на backend-е; +- frontend должен получать уже готовое отображаемое имя. + +### Где исправлять + +Исправление должно затронуть все состояния страницы: + +```text +association_type=all +association_type=sez +association_type=technoparks +association_type=clusters +``` + +Поля: + +- `region_ranking.rows[].region_name`; +- `top_industries.rows[].industry_name`; +- при необходимости тот же источник справочников для `/analytics/filters/`, если там используются те же + placeholder-значения в `regions[].label` или `industries[].label`. + +### Acceptance criteria + +Response не должен содержать placeholder-паттерны: + +```text +region_ranking.rows[].region_name != "Субъект РФ " +top_industries.rows[].industry_name != "Отрасль " +``` + +Для каждого `association_type` нужно проверить: + +```text +GET /api/v1/competence-map/analytics/?association_type=all +GET /api/v1/competence-map/analytics/?association_type=sez +GET /api/v1/competence-map/analytics/?association_type=technoparks +GET /api/v1/competence-map/analytics/?association_type=clusters +``` + +И убедиться, что: + +- все строки `region_ranking.rows[]` имеют заполненный `region_name`; +- все строки `top_industries.rows[]` имеют заполненный `industry_name`; +- значения являются реальными названиями, а не техническими fallback-строками. + +--- + +## Проблема 2. Не приходят строки таблицы приоритетных направлений ОЭЗ + +### Где видно + +В состоянии `association_type=sez` блок +`Типы ОЭЗ и приоритетные направления деятельности` отображает donut с типами ОЭЗ, но таблица +`Приоритетные направления деятельности ОЭЗ` пустая и показывает `Нет данных`. + +При этом по контракту этот блок должен состоять из двух частей: + +- `main_block.sez_types` - типы ОЭЗ; +- `main_block.priority_directions` - таблица приоритетных направлений ОЭЗ. + +### Затронутый endpoint + +```text +GET /api/v1/competence-map/analytics/?association_type=sez +``` + +### Затронутые поля + +```json +{ + "association_type": "sez", + "main_block": { + "type": "sez_types_and_priority_directions", + "sez_types": {}, + "priority_directions": { + "title": "Приоритетные направления деятельности ОЭЗ", + "sort": { + "field": "sez_count", + "direction": "desc" + }, + "ranking_method": "ordinal", + "rows": [] + } + } +} +``` + +Сейчас `rows` фактически пустой или не содержит нужных данных. + +### Требуемый формат + +`main_block.priority_directions.rows[]` должен возвращать строки в формате: + +```json +{ + "rank": 1, + "direction_id": "ict", + "direction_name": "Информационно-коммуникационные технологии", + "sez_count": 4 +} +``` + +Полный блок должен сохранять структуру: + +```json +{ + "priority_directions": { + "title": "Приоритетные направления деятельности ОЭЗ", + "sort": { + "field": "sez_count", + "direction": "desc" + }, + "ranking_method": "ordinal", + "rows": [ + { + "rank": 1, + "direction_id": "ict", + "direction_name": "Информационно-коммуникационные технологии", + "sez_count": 4 + }, + { + "rank": 2, + "direction_id": "aircraft-maintenance", + "direction_name": "Обслуживание и ремонт воздушных судов", + "sez_count": 3 + } + ] + } +} +``` + +### Требования + +- `main_block.type` для `association_type=sez` должен быть `sez_types_and_priority_directions`; +- `main_block.priority_directions` должен присутствовать вместе с `main_block.sez_types`; +- `priority_directions.rows` должен быть массивом; +- строки должны быть отсортированы на backend-е согласно `sort`; +- `rank` должен быть рассчитан backend-ом и начинаться с `1`; +- `direction_id` должен быть стабильным id направления; +- `direction_name` должен быть реальным названием направления, а не placeholder; +- `sez_count` должен быть числом `>= 0`; +- пустой `rows: []` допустим только если после применения фильтров действительно нет ни одного направления. + +### Acceptance criteria + +Для запроса без дополнительных фильтров: + +```text +GET /api/v1/competence-map/analytics/?association_type=sez +``` + +Ожидается: + +- `main_block.priority_directions.rows.length > 0`; +- каждая строка имеет `rank`, `direction_id`, `direction_name`, `sez_count`; +- `direction_name` заполнен реальным названием; +- `sez_count` является числом; +- таблица на frontend-е перестает показывать `Нет данных`, если данные в источнике есть. + +--- + +## Проблема 3. Не приходят KPI-карточки численности сотрудников для ОЭЗ и технопарков + +### Где видно + +На вкладках `ОЭЗ` и `Технопарки` есть блок `Численность сотрудников`. +Сейчас карточки отображаются с прочерками, потому что backend не возвращает данные в `employee_stats`. + +Этот блок является серверным состоянием страницы: frontend не может рассчитать эти значения локально. + +### Затронутые endpoint-ы + +```text +GET /api/v1/competence-map/analytics/?association_type=sez +GET /api/v1/competence-map/analytics/?association_type=technoparks +``` + +### ОЭЗ. Требуемые поля + +Для `association_type=sez` backend должен возвращать: + +```json +{ + "association_type": "sez", + "employee_stats": { + "items": [ + { + "code": "opk_resident_employees_count", + "label": "Численность сотрудников организаций ОПК-резидентов ОЭЗ", + "value": 1678, + "value_type": "integer", + "numerator": 1678, + "denominator": null + }, + { + "code": "sez_employees_count", + "label": "Численность сотрудников ОЭЗ", + "value": 5235, + "value_type": "integer", + "numerator": 5235, + "denominator": null + } + ] + } +} +``` + +Обязательные метрики для ОЭЗ: + +| `code` | Назначение | `value_type` | +|---|---|---| +| `opk_resident_employees_count` | численность сотрудников организаций ОПК-резидентов ОЭЗ | `integer` | +| `sez_employees_count` | общая численность сотрудников ОЭЗ | `integer` | + +### Технопарки. Требуемые поля + +Для `association_type=technoparks` backend должен возвращать: + +```json +{ + "association_type": "technoparks", + "employee_stats": { + "items": [ + { + "code": "opk_resident_employees_count", + "label": "Численность сотрудников организаций ОПК-резидентов технопарков", + "value": 1678, + "value_type": "integer", + "numerator": 1678, + "denominator": null + }, + { + "code": "opk_resident_employees_share_percent", + "label": "Доля сотрудников организаций ОПК-резидентов технопарков", + "value": 35.0, + "value_type": "percent", + "numerator": 1678, + "denominator": 4794 + } + ] + } +} +``` + +Обязательные метрики для технопарков: + +| `code` | Назначение | `value_type` | +|---|---|---| +| `opk_resident_employees_count` | численность сотрудников организаций ОПК-резидентов технопарков | `integer` | +| `opk_resident_employees_share_percent` | доля сотрудников организаций ОПК-резидентов технопарков | `percent` | + +### Требования + +- `employee_stats` для `association_type=sez` должен быть объектом, а не `null`; +- `employee_stats` для `association_type=technoparks` должен быть объектом, а не `null`; +- `employee_stats.items` должен содержать все обязательные метрики для выбранного типа; +- `items[].code` должен быть стабильным; +- `items[].label` должен быть готовым отображаемым названием; +- `items[].value` должен быть числом; +- для процентных метрик `numerator` и `denominator` обязательны, если знаменатель известен; +- `denominator` у процентной метрики не должен быть `0`; +- `0` нужно возвращать как `0`, если показатель реально равен нулю; +- не возвращать `employee_stats: null` для применимого блока только потому, что расчет ещё не реализован. + +### Когда `employee_stats` может быть `null` + +`employee_stats: null` допустим для состояний, где блок сотрудников не применяется: + +```text +association_type=all +association_type=clusters +``` + +Для `sez` и `technoparks` блок применим и должен быть заполнен. + +### Acceptance criteria + +Для запроса: + +```text +GET /api/v1/competence-map/analytics/?association_type=sez +``` + +Ожидается: + +- `employee_stats !== null`; +- `employee_stats.items.length >= 2`; +- есть item с `code = "opk_resident_employees_count"`; +- есть item с `code = "sez_employees_count"`; +- оба `value` являются числами. + +Для запроса: + +```text +GET /api/v1/competence-map/analytics/?association_type=technoparks +``` + +Ожидается: + +- `employee_stats !== null`; +- `employee_stats.items.length >= 2`; +- есть item с `code = "opk_resident_employees_count"`; +- есть item с `code = "opk_resident_employees_share_percent"`; +- оба `value` являются числами; +- для `opk_resident_employees_share_percent` заполнены `numerator` и `denominator`. + +--- + +## Сводка исправлений по backend response + +| Проблема | Endpoint | Поля | Что нужно сделать | +|---|---|---|---| +| Placeholder субъектов РФ | `/analytics/` для всех `association_type` | `region_ranking.rows[].region_name` | возвращать реальные названия субъектов РФ | +| Placeholder отраслей | `/analytics/` для всех `association_type` | `top_industries.rows[].industry_name` | возвращать реальные названия отраслей | +| Пустая таблица направлений ОЭЗ | `/analytics/?association_type=sez` | `main_block.priority_directions.rows[]` | вернуть строки направлений ОЭЗ | +| Нет карточек сотрудников ОЭЗ | `/analytics/?association_type=sez` | `employee_stats.items[]` | вернуть две KPI-метрики сотрудников | +| Нет карточек сотрудников технопарков | `/analytics/?association_type=technoparks` | `employee_stats.items[]` | вернуть две KPI-метрики сотрудников | + +--- + +## Минимальный backend checklist + +Перед отдачей исправления нужно проверить: + +```text +GET /api/v1/competence-map/analytics/?association_type=all +GET /api/v1/competence-map/analytics/?association_type=sez +GET /api/v1/competence-map/analytics/?association_type=technoparks +GET /api/v1/competence-map/analytics/?association_type=clusters +``` + +Проверки: + +- `region_ranking.rows[].region_name` не содержит `Субъект РФ`; +- `top_industries.rows[].industry_name` не содержит `Отрасль `; +- для `association_type=sez` заполнен `main_block.priority_directions.rows`; +- для `association_type=sez` заполнен `employee_stats.items`; +- для `association_type=technoparks` заполнен `employee_stats.items`; +- все числовые поля возвращаются числами, а не строками; +- пустые массивы используются только когда данных действительно нет по выбранным фильтрам; +- response соответствует `openapi.json` и контракту + `docs/info/backend-endpoints-competence-map-analytics-from-layouts.md`. diff --git a/src/style.css b/src/style.css index 9b7bd14..515c753 100644 --- a/src/style.css +++ b/src/style.css @@ -989,7 +989,8 @@ select { .analytics-table { width: 100%; table-layout: fixed; - border-collapse: collapse; + border-collapse: separate; + border-spacing: 0; color: var(--Infographic-Main-Dark-Blue, #022456); font-size: 12px; line-height: 16px; @@ -997,11 +998,18 @@ select { .analytics-table th, .analytics-table td { - padding: 6px 7px; text-align: left; vertical-align: top; } +.analytics-table th { + padding: 6px 7px; +} + +.analytics-table td { + padding: 8px 7px; +} + .analytics-table th { position: sticky; top: 0; @@ -1009,26 +1017,38 @@ select { background: var(--Infographic-Base-White, #ffffff); color: rgb(2 36 86 / 0.66); font-weight: 800; - transition: background-color 160ms ease; } .analytics-table th:hover { - background: rgb(232 236 242 / 0.9); + background: var(--Infographic-Base-White, #ffffff); } -.analytics-table tbody tr { +.analytics-table tbody td { transition: background-color 160ms ease; } -.analytics-table tbody tr:hover { +.analytics-table tbody tr:hover td { background: rgb(243 243 243 / 0.7); } +.analytics-table tbody tr:hover td:first-child { + border-radius: 10px 0 0 10px; +} + +.analytics-table tbody tr:hover td:last-child { + border-radius: 0 10px 10px 0; +} + .analytics-table tbody tr.analytics-table-empty-row:hover, .ranking-table tbody tr.ranking-table-empty-row:hover { background: transparent; } +.analytics-table tbody tr.analytics-table-empty-row:hover td, +.ranking-table tbody tr.ranking-table-empty-row:hover td { + background: transparent; +} + .analytics-table th:first-child, .analytics-table td:first-child { width: 44px; @@ -1063,7 +1083,30 @@ select { .sez-priority-table th:nth-child(3), .sez-priority-table td:nth-child(3) { - width: 140px; + width: 170px; +} + +.analytics-table-header-label { + display: inline-flex; + width: 100%; + min-width: 0; + min-height: 32px; + align-items: center; + border-radius: 10px; + padding: 6px 10px; + transition: background-color 160ms ease; +} + +.analytics-table th:first-child .analytics-table-header-label { + justify-content: center; +} + +.analytics-table th:last-child .analytics-table-header-label { + justify-content: flex-end; +} + +.analytics-table th:hover .analytics-table-header-label { + background: rgb(232 236 242 / 0.9); } .analytics-table-sort-button { @@ -1072,20 +1115,33 @@ select { min-width: 0; align-items: center; justify-content: flex-end; - gap: 4px; + gap: 6px; border: 0; background: transparent; - padding: 0; + border-radius: 10px; + min-height: 32px; + padding: 6px 10px; color: inherit; font: inherit; font-weight: inherit; text-align: right; + transition: background-color 160ms ease; cursor: pointer; } +.analytics-table th:hover .analytics-table-sort-button, +.analytics-table-sort-button:hover, +.analytics-table-sort-button:focus-visible { + background: rgb(232 236 242 / 0.9); +} + .analytics-table-sort-button span { + flex: 0 1 auto; min-width: 0; - overflow-wrap: anywhere; + overflow: visible; + text-overflow: clip; + overflow-wrap: normal; + white-space: nowrap; } .analytics-table-sort-button svg { @@ -1093,7 +1149,7 @@ select { min-width: 15px; height: 15px; flex: 0 0 15px; - color: rgb(2 36 86 / 0.28); + color: rgb(2 36 86 / 0.42); stroke-width: 2.25; transform-origin: center; transition: @@ -1103,7 +1159,7 @@ select { .analytics-table-sort-button:hover svg, .analytics-table-sort-button.is-active svg { - color: rgb(2 36 86 / 0.62); + color: rgb(2 36 86 / 0.68); } .analytics-table-sort-button.is-desc svg { @@ -1173,9 +1229,10 @@ select { .ranking-table { width: 100%; - min-width: 0; + min-width: 560px; table-layout: fixed; - border-collapse: collapse; + border-collapse: separate; + border-spacing: 0; color: var(--Infographic-Main-Dark-Blue, #022456); font-size: 12.5px; line-height: 17px; @@ -1186,11 +1243,11 @@ select { } .ranking-table__region-col { - width: 45%; + width: auto; } .ranking-table__metric-col { - width: 16%; + width: 136px; } .analytics-dashboard-grid.is-clusters .ranking-table__region-col, @@ -1202,16 +1259,23 @@ select { .analytics-dashboard-grid.is-clusters .ranking-table__metric-col, .analytics-dashboard-grid.is-sez .ranking-table__metric-col, .analytics-dashboard-grid.is-technoparks .ranking-table__metric-col { - width: 112px; + width: 136px; } .ranking-table th, .ranking-table td { - padding: 6px 8px; text-align: left; vertical-align: middle; } +.ranking-table th { + padding: 6px 8px; +} + +.ranking-table td { + padding: 8px 8px; +} + .ranking-table th { position: sticky; top: 0; @@ -1219,11 +1283,10 @@ select { background: var(--Infographic-Base-White, #ffffff); color: var(--Infographic-Main-Dark-Blue, #022456); font-weight: 800; - transition: background-color 160ms ease; } .ranking-table th:hover { - background: rgb(232 236 242 / 0.96); + background: var(--Infographic-Base-White, #ffffff); } .ranking-table th:first-child, @@ -1242,29 +1305,60 @@ select { text-align: right; } +.ranking-table-header-label { + display: inline-flex; + width: 100%; + min-width: 0; + min-height: 32px; + align-items: center; + border-radius: 10px; + padding: 6px 10px; + transition: background-color 160ms ease; +} + +.ranking-table th:first-child .ranking-table-header-label { + justify-content: center; +} + +.ranking-table th:nth-child(n + 3) .ranking-table-header-label { + justify-content: flex-end; +} + +.ranking-table th:hover .ranking-table-header-label { + background: rgb(232 236 242 / 0.96); +} + .ranking-table-sort-button { display: inline-flex; width: 100%; min-width: 0; align-items: center; justify-content: flex-end; - gap: 4px; + gap: 6px; border: 0; background: transparent; - padding: 0; + border-radius: 10px; + min-height: 32px; + padding: 6px 10px; color: inherit; font: inherit; font-weight: inherit; text-align: right; + transition: background-color 160ms ease; cursor: pointer; } -.ranking-table-sort-button span { - min-width: 0; - overflow-wrap: anywhere; +.ranking-table th:hover .ranking-table-sort-button, +.ranking-table-sort-button:hover, +.ranking-table-sort-button:focus-visible { + background: rgb(232 236 242 / 0.96); } -.ranking-table th:nth-child(n + 3) .ranking-table-sort-button span { +.ranking-table-sort-button span { + flex: 0 1 auto; + min-width: 0; + overflow: visible; + text-overflow: clip; overflow-wrap: normal; white-space: nowrap; } @@ -1274,7 +1368,7 @@ select { min-width: 15px; height: 15px; flex: 0 0 15px; - color: rgb(2 36 86 / 0.28); + color: rgb(2 36 86 / 0.42); stroke-width: 2.25; transform-origin: center; transition: @@ -1284,17 +1378,29 @@ select { .ranking-table-sort-button:hover svg, .ranking-table-sort-button.is-active svg { - color: rgb(2 36 86 / 0.62); + color: rgb(2 36 86 / 0.68); } .ranking-table-sort-button.is-desc svg { transform: rotate(180deg); } -.ranking-table tbody tr:hover { +.ranking-table tbody td { + transition: background-color 160ms ease; +} + +.ranking-table tbody tr:hover td { background: rgb(243 243 243 / 0.7); } +.ranking-table tbody tr:hover td:first-child { + border-radius: 10px 0 0 10px; +} + +.ranking-table tbody tr:hover td:last-child { + border-radius: 0 10px 10px 0; +} + .analytics-chart-legend { display: flex; flex-wrap: wrap; @@ -1446,7 +1552,7 @@ select { .cluster-specializations-table th:nth-child(3), .cluster-specializations-table td:nth-child(3) { - width: 170px; + width: 220px; text-align: right; } diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue index dd7685c..ea65813 100644 --- a/src/views/DashboardView.vue +++ b/src/views/DashboardView.vue @@ -206,13 +206,6 @@ const dashboardTabs = computed(() => [ ...option, kind: 'association' as const, })), - { - associations_count: null, - disabled: false, - id: REGION_RATING_TAB_ID, - kind: 'region-rating', - label: 'Рейтинг субъектов РФ', - }, ]) const isRegionRatingTabActive = computed(() => activeDashboardTab.value === REGION_RATING_TAB_ID) const isDashboardDataLoading = computed(() => dashboard.isAnalyticsFetching) @@ -979,8 +972,14 @@ const formatAreaUnit = (unit: string): string => (unit === 'ha' ? 'га' : unit) - - + + @@ -1066,8 +1065,12 @@ const formatAreaUnit = (unit: string): string => (unit === 'ha' ? 'га' : unit)
Наименование направления деятельности + + + + Наименование направления деятельности + +
- - + + @@ -1117,8 +1120,14 @@ const formatAreaUnit = (unit: string): string => (unit === 'ha' ? 'га' : unit)
Название технопарка + + + Название технопарка +
- - + + @@ -1297,8 +1306,12 @@ const formatAreaUnit = (unit: string): string => (unit === 'ha' ? 'га' : unit) - - + +
Наименование специализации + + + + Наименование специализации + + @@ -1138,8 +1147,8 @@ const formatAreaUnit = (unit: string): string => (unit === 'ha' ? 'га' : unit) aria-label="Сортировать специализации по количеству кластеров" @click="updateMainTableSort('clusters', 'clusters_count')" > -
Субъект РФ + + + Субъект РФ + (unit === 'ha' ? 'га' : unit) :aria-label="`Сортировать рейтинг субъектов по колонке ${column.label}`" @click="updateRegionRankingSort(column.key)" > -