Compare commits
No commits in common. "99f6f5ab4e61e6bbafc157df36a0ef1574d4cad5" and "fd40f91b44f5cf5a630a1c4d65eb38cb9fb76066" have entirely different histories.
99f6f5ab4e
...
fd40f91b44
|
|
@ -1,7 +0,0 @@
|
|||
/local/
|
||||
/.idea/
|
||||
/.git/
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
/vendor/
|
||||
*.md
|
||||
|
|
@ -6,8 +6,6 @@ POSTGRES_PASSWORD=admin
|
|||
POSTGRES_PORT=5432
|
||||
TZ=Asia/Singapore
|
||||
|
||||
# Для Docker POSTGRES_HOST переопределяется в docker-compose.yml на db
|
||||
|
||||
JIRA_URL="https://your-domain.atlassian.net"
|
||||
JIRA_TOKEN=your-api-token
|
||||
JIRA_MAX_RESULTS=1000
|
||||
|
|
|
|||
|
|
@ -2,5 +2,3 @@
|
|||
/.idea/
|
||||
/local/
|
||||
.env
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
|
|
|
|||
|
|
@ -16,41 +16,6 @@ services:
|
|||
- "5432:5432"
|
||||
networks: [reports-network]
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
php:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/php/Dockerfile
|
||||
container_name: reports_php
|
||||
env_file: [.env]
|
||||
environment:
|
||||
POSTGRES_HOST: db
|
||||
volumes:
|
||||
- ./.env:/var/www/html/.env:ro
|
||||
- ./src:/var/www/html/src:ro
|
||||
- ./public:/var/www/html/public:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks: [reports-network]
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/nginx/Dockerfile
|
||||
container_name: reports_nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- php
|
||||
networks: [reports-network]
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
reports-network:
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
FROM node:22-alpine AS frontend-build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=frontend-build /app/dist /usr/share/nginx/html
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 16m;
|
||||
|
||||
location /api/ {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass php:9000;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
fastcgi_param QUERY_STRING $query_string;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
gzip_min_length 256;
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
FROM php:8.4-fpm-alpine
|
||||
|
||||
RUN apk add --no-cache \
|
||||
postgresql-dev \
|
||||
libzip-dev \
|
||||
&& docker-php-ext-install pdo_pgsql opcache
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
COPY composer.json composer.lock ./
|
||||
RUN composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-req=ext-readline
|
||||
|
||||
COPY init.php ./
|
||||
COPY src ./src
|
||||
COPY public ./public
|
||||
|
||||
RUN chown -R www-data:www-data /var/www/html
|
||||
|
|
@ -68,10 +68,6 @@ VALUES
|
|||
(49, 'Екатерина Казакова', 'e.kazakova', 'designer'),
|
||||
(65, 'Михаил Ягудин', 'm.yagudin', 'frontend'),
|
||||
(66, 'Антон Буковщиков', 'a.bukovschikov', 'analytic'),
|
||||
(71, 'Алексей Сапронов', 'a.sapronov', 'backend'),
|
||||
(72, 'Василий Шевлоков', 'v.shevlokov', 'frontend'),
|
||||
(73, 'Кирилл Кетов', 'k.ketov', 'qa'),
|
||||
(74, 'Илья Шленский', 'i.shlenskiy', 'qa'),
|
||||
|
||||
-- bryansk
|
||||
(50, 'Анастасия Абрамчук', 'a.abramchuk', 'designer'),
|
||||
|
|
@ -153,10 +149,6 @@ VALUES
|
|||
(1, 49, '2026-01-01 00:00:00Z'),
|
||||
(1, 65, '2026-01-01 00:00:00Z'),
|
||||
(1, 66, '2026-01-01 00:00:00Z'),
|
||||
(1, 71, '2026-06-20 00:00:00Z'),
|
||||
(1, 72, '2026-06-20 00:00:00Z'),
|
||||
(1, 73, '2026-06-20 00:00:00Z'),
|
||||
(1, 74, '2026-06-20 00:00:00Z'),
|
||||
|
||||
-- bryansk
|
||||
(4, 50, '2026-01-01 00:00:00Z'),
|
||||
|
|
|
|||
|
|
@ -1,102 +0,0 @@
|
|||
# API отчётов по спринтам
|
||||
|
||||
REST API для веб-интерфейса сводки по спринтам. Все ответы — JSON в кодировке UTF-8.
|
||||
|
||||
## Базовый URL
|
||||
|
||||
| Окружение | URL |
|
||||
|-----------|-----|
|
||||
| Docker (nginx) | `http://localhost/api` |
|
||||
| Локальная разработка | `http://localhost:8080/api` |
|
||||
|
||||
Точка входа: `public/index.php`.
|
||||
|
||||
## Общие сведения
|
||||
|
||||
- Поддерживаются только **GET**-запросы (чтение данных).
|
||||
- Заголовок ответа: `Content-Type: application/json; charset=utf-8`.
|
||||
- Авторизация не требуется.
|
||||
- Даты и время возвращаются в формате PostgreSQL `TIMESTAMPTZ` (ISO 8601 с часовым поясом).
|
||||
- Поля с отсутствующими данными имеют значение `null`.
|
||||
|
||||
## Коды ответов
|
||||
|
||||
| Код | Описание |
|
||||
|-----|----------|
|
||||
| `200` | Успешный ответ |
|
||||
| `204` | `OPTIONS` (preflight) |
|
||||
| `404` | Маршрут или ресурс не найден |
|
||||
| `500` | Ошибка сервера |
|
||||
|
||||
### Формат ошибки
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Описание ошибки"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Документация по сущностям
|
||||
|
||||
| Сущность | Описание |
|
||||
|----------|----------|
|
||||
| [Команды](teams.md) | Список команд |
|
||||
| [Спринты](sprints.md) | Сводные метрики по спринтам |
|
||||
| [Задачи](tasks.md) | Задачи спринта и команды (с пагинацией) |
|
||||
| [Конфигурация](config.md) | Публичные настройки для фронтенда |
|
||||
|
||||
---
|
||||
|
||||
## Сводка эндпоинтов
|
||||
|
||||
| Метод | Путь | Сущность | Описание |
|
||||
|-------|------|----------|----------|
|
||||
| `GET` | [`/api/teams`](teams.md#get-apiteams) | [Team](teams.md) | Список команд |
|
||||
| `GET` | [`/api/teams/{teamId}/sprints`](sprints.md#get-apiteamsteamidsprints) | [Sprint](sprints.md) | Спринты команды со сводкой |
|
||||
| `GET` | [`/api/teams/{teamId}/tasks`](tasks.md#get-apiteamsteamidtasks) | [PaginatedTeamTasks](tasks.md) | Все задачи команды (пагинация) |
|
||||
| `GET` | [`/api/sprints/{sprintId}`](sprints.md#get-apisprintssprintid) | [Sprint](sprints.md) | Один спринт со сводкой |
|
||||
| `GET` | [`/api/sprints/{sprintId}/tasks`](tasks.md#get-apisprintssprintidtasks) | [SprintTask](tasks.md) | Задачи спринта |
|
||||
| `GET` | [`/api/config`](config.md#get-apiconfig) | [AppConfig](config.md) | Публичная конфигурация |
|
||||
|
||||
### Связи между сущностями
|
||||
|
||||
```
|
||||
Team ──< Sprint ──< SprintTask
|
||||
│ ↑
|
||||
└──── TeamTask ──────┘ (через sprint_id, sprint_name)
|
||||
AppConfig (jira_url для ссылок на задачи)
|
||||
```
|
||||
|
||||
- [Team](teams.md) → [Sprint](sprints.md): `GET /api/teams/{teamId}/sprints`
|
||||
- [Team](teams.md) → [TeamTask](tasks.md): `GET /api/teams/{teamId}/tasks`
|
||||
- [Sprint](sprints.md) → [SprintTask](tasks.md): `GET /api/sprints/{sprintId}/tasks`
|
||||
- [AppConfig](config.md) используется вместе с [SprintTask](tasks.md) и [TeamTask](tasks.md) для ссылок `{jira_url}/browse/{task_key}`
|
||||
|
||||
---
|
||||
|
||||
## Быстрые примеры
|
||||
|
||||
```bash
|
||||
curl http://localhost/api/teams
|
||||
curl http://localhost/api/config
|
||||
curl http://localhost/api/teams/1/sprints
|
||||
curl http://localhost/api/teams/1/tasks
|
||||
curl http://localhost/api/teams/1/tasks?page=2&per_page=50
|
||||
curl http://localhost/api/sprints/42
|
||||
curl http://localhost/api/sprints/42/tasks
|
||||
```
|
||||
|
||||
Подробности — в документации соответствующей сущности.
|
||||
|
||||
---
|
||||
|
||||
## Реализация
|
||||
|
||||
| Компонент | Путь |
|
||||
|-----------|------|
|
||||
| Точка входа | `public/index.php` |
|
||||
| Бизнес-логика | `src/Features/Reports/ReportsApi.php` |
|
||||
|
||||
Перцентили в сводке по спринтам вычисляются в PostgreSQL функцией `percentile_cont`. Подробнее — в [документации по спринтам](sprints.md#метрики).
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
# Конфигурация (AppConfig)
|
||||
|
||||
← [Сводка API](README.md)
|
||||
|
||||
Сущность **AppConfig** — публичные настройки для фронтенда. Секреты (токены, пароли) **не** включаются в ответ.
|
||||
|
||||
## Связанные сущности
|
||||
|
||||
- [Задачи](tasks.md) — `jira_url` используется для ссылок `{jira_url}/browse/{task_key}` (в том числе для [TeamTask](tasks.md))
|
||||
|
||||
---
|
||||
|
||||
## Модель данных
|
||||
|
||||
### AppConfig
|
||||
|
||||
```json
|
||||
{
|
||||
"jira_url": "https://your-domain.atlassian.net"
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `jira_url` | `string` | Базовый URL Jira без завершающего `/`. Берётся из переменной окружения `JIRA_URL` |
|
||||
|
||||
---
|
||||
|
||||
## Эндпоинты
|
||||
|
||||
### `GET /api/config`
|
||||
|
||||
Возвращает публичную конфигурацию.
|
||||
|
||||
**Ответ:** `200` — объект `AppConfig`
|
||||
|
||||
**Пример**
|
||||
|
||||
```bash
|
||||
curl http://localhost/api/config
|
||||
```
|
||||
|
||||
**Использование**
|
||||
|
||||
```text
|
||||
https://your-domain.atlassian.net/browse/TS-1234
|
||||
```
|
||||
|
||||
где `https://your-domain.atlassian.net` — значение `jira_url`, `TS-1234` — поле `task_key` из [SprintTask](tasks.md).
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
# Спринты (Sprint)
|
||||
|
||||
← [Сводка API](README.md)
|
||||
|
||||
Сущность **Sprint** — закрытый спринт команды со сводными метриками по задачам.
|
||||
|
||||
## Связанные сущности
|
||||
|
||||
- [Команды](teams.md) — родительская сущность (`team_id`, `team_name`)
|
||||
- [Задачи](tasks.md) — детализация по задачам спринта: `GET /api/sprints/{sprintId}/tasks`; все задачи команды: `GET /api/teams/{teamId}/tasks`
|
||||
|
||||
---
|
||||
|
||||
## Модель данных
|
||||
|
||||
### Sprint
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"team_id": 1,
|
||||
"team_name": "ts",
|
||||
"jira_sprint_id": 2801,
|
||||
"sprint_name": "TS 2026_1 до 26.01",
|
||||
"sprint_activate_datetime": "2026-01-13 16:35:37.082+04",
|
||||
"sprint_complete_datetime": "2026-01-27 16:07:34.138+04",
|
||||
"sprint_completion_percentage": 96.08,
|
||||
"lead_time_max": 175,
|
||||
"lead_time_min": 1,
|
||||
"lead_time_p95": 23.5,
|
||||
"lead_time_p80": 15.0,
|
||||
"lead_time_p50": 6.0,
|
||||
"test_count_max": 4,
|
||||
"test_count_min": 0,
|
||||
"test_count_p95": 2.0,
|
||||
"test_count_p80": 1.0,
|
||||
"test_count_p50": 0.0,
|
||||
"efficiency_min": 1.46,
|
||||
"efficiency_max": 84.17,
|
||||
"efficiency_p50": 18.75,
|
||||
"recast_max": 109.69,
|
||||
"recast_min": -70.83,
|
||||
"recast_p95": 88.13,
|
||||
"recast_p80": 33.33,
|
||||
"recast_p50": -7.5
|
||||
}
|
||||
```
|
||||
|
||||
### Основные поля
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `id` | `integer` | Идентификатор спринта в БД |
|
||||
| `team_id` | `integer` | Идентификатор [команды](teams.md) |
|
||||
| `team_name` | `string` | Название команды |
|
||||
| `jira_sprint_id` | `integer` | Идентификатор спринта в Jira |
|
||||
| `sprint_name` | `string` | Название спринта |
|
||||
| `sprint_activate_datetime` | `string` | Дата и время начала спринта |
|
||||
| `sprint_complete_datetime` | `string` | Дата и время завершения спринта |
|
||||
| `sprint_completion_percentage` | `number \| null` | Процент закрытия спринта (0–100) |
|
||||
|
||||
## Метрики
|
||||
|
||||
### Lead Time (рабочие дни)
|
||||
|
||||
Агрегаты по полю `tasks.lead_time_days` для всех задач спринта с заполненным Lead Time.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `lead_time_max` | `integer \| null` | Максимум |
|
||||
| `lead_time_min` | `integer \| null` | Минимум |
|
||||
| `lead_time_p95` | `number \| null` | 95-й перцентиль |
|
||||
| `lead_time_p80` | `number \| null` | 80-й перцентиль |
|
||||
| `lead_time_p50` | `number \| null` | 50-й перцентиль (медиана) |
|
||||
|
||||
### Количество заходов на тестирование
|
||||
|
||||
Агрегаты по полю `tasks.test_count` **только для задач**, где исполнитель имеет роль `backend` или `frontend`.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `test_count_max` | `integer \| null` | Максимум |
|
||||
| `test_count_min` | `integer \| null` | Минимум |
|
||||
| `test_count_p95` | `number \| null` | 95-й перцентиль |
|
||||
| `test_count_p80` | `number \| null` | 80-й перцентиль |
|
||||
| `test_count_p50` | `number \| null` | 50-й перцентиль |
|
||||
|
||||
### Flow Efficiency (КПД, %)
|
||||
|
||||
Агрегаты по полю `tasks.efficiency_percentage` для всех задач спринта с заполненным значением.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `efficiency_min` | `number \| null` | Минимум |
|
||||
| `efficiency_max` | `number \| null` | Максимум |
|
||||
| `efficiency_p50` | `number \| null` | 50-й перцентиль |
|
||||
|
||||
### Переработка (%, `recast_percentage`)
|
||||
|
||||
Агрегаты по полю `tasks.recast_percentage`. Отрицательные значения означают выполнение быстрее оценки.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `recast_max` | `number \| null` | Максимум |
|
||||
| `recast_min` | `number \| null` | Минимум |
|
||||
| `recast_p95` | `number \| null` | 95-й перцентиль |
|
||||
| `recast_p80` | `number \| null` | 80-й перцентиль |
|
||||
| `recast_p50` | `number \| null` | 50-й перцентиль |
|
||||
|
||||
Перцентили вычисляются в PostgreSQL функцией `percentile_cont`.
|
||||
|
||||
---
|
||||
|
||||
## Эндпоинты
|
||||
|
||||
### `GET /api/teams/{teamId}/sprints`
|
||||
|
||||
Возвращает спринты [команды](teams.md) со сводными метриками.
|
||||
|
||||
**Параметры пути**
|
||||
|
||||
| Параметр | Тип | Описание |
|
||||
|----------|-----|----------|
|
||||
| `teamId` | `integer` | Идентификатор команды |
|
||||
|
||||
**Ответ:** `200` — массив `Sprint[]`, отсортированный по дате начала (от старых к новым)
|
||||
|
||||
**Пример**
|
||||
|
||||
```bash
|
||||
curl http://localhost/api/teams/1/sprints
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/sprints/{sprintId}`
|
||||
|
||||
Возвращает один спринт со сводными метриками.
|
||||
|
||||
**Параметры пути**
|
||||
|
||||
| Параметр | Тип | Описание |
|
||||
|----------|-----|----------|
|
||||
| `sprintId` | `integer` | Идентификатор спринта |
|
||||
|
||||
**Ответ:** `200` — объект `Sprint`
|
||||
|
||||
**Ошибки:** `404` — спринт не найден
|
||||
|
||||
**Пример**
|
||||
|
||||
```bash
|
||||
curl http://localhost/api/sprints/42
|
||||
```
|
||||
|
||||
См. также: [задачи этого спринта](tasks.md#get-apisprintssprintidtasks)
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
# Задачи (SprintTask, TeamTask)
|
||||
|
||||
← [Сводка API](README.md)
|
||||
|
||||
Сущность **SprintTask** — задача, закрытая в [спринте](sprints.md). Содержит метрики по отдельной задаче.
|
||||
|
||||
Сущность **TeamTask** — расширение `SprintTask` с информацией о спринте. Используется в списке всех задач [команды](teams.md).
|
||||
|
||||
## Связанные сущности
|
||||
|
||||
- [Команды](teams.md) — список всех задач команды: `GET /api/teams/{teamId}/tasks`
|
||||
- [Спринты](sprints.md) — родительская сущность (`sprint_id` в БД; в ответе `GET /api/sprints/{sprintId}/tasks` не возвращается)
|
||||
- [Конфигурация](config.md) — `jira_url` для формирования ссылки на задачу в Jira
|
||||
|
||||
---
|
||||
|
||||
## Модель данных
|
||||
|
||||
### SprintTask
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 101,
|
||||
"task_type": "backend",
|
||||
"task_key": "TS-1234",
|
||||
"task_title": "Пример задачи",
|
||||
"employee_name": "Иван Иванов",
|
||||
"employee_role": "backend",
|
||||
"estimate_seconds": 28800,
|
||||
"total_time_seconds": 32400,
|
||||
"take_in_work_datetime": "2026-01-14 10:00:00+04",
|
||||
"resolution_datetime": "2026-01-20 16:30:00+04",
|
||||
"lead_time_days": 5,
|
||||
"recast_percentage": 12.5,
|
||||
"efficiency_percentage": 45.0,
|
||||
"review_count": 2,
|
||||
"test_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `id` | `integer` | Идентификатор записи в БД |
|
||||
| `task_type` | `string` | Тип задачи |
|
||||
| `task_key` | `string` | Ключ задачи в Jira |
|
||||
| `task_title` | `string` | Заголовок задачи |
|
||||
| `employee_name` | `string \| null` | ФИО исполнителя |
|
||||
| `employee_role` | `string \| null` | Роль исполнителя |
|
||||
| `estimate_seconds` | `integer` | Оценка в секундах |
|
||||
| `total_time_seconds` | `integer` | Фактически затраченное время в секундах |
|
||||
| `take_in_work_datetime` | `string \| null` | Дата и время взятия в работу |
|
||||
| `resolution_datetime` | `string \| null` | Дата и время закрытия |
|
||||
| `lead_time_days` | `integer \| null` | System Lead Time в рабочих днях |
|
||||
| `recast_percentage` | `number \| null` | Переработка относительно оценки, % |
|
||||
| `efficiency_percentage` | `number \| null` | КПД (Flow Efficiency), % |
|
||||
| `review_count` | `integer` | Количество заходов на ревью |
|
||||
| `test_count` | `integer` | Количество заходов на тестирование |
|
||||
|
||||
### Роли исполнителей (`employee_role`)
|
||||
|
||||
`analytic`, `backend`, `designer`, `frontend`, `qa`, `project`
|
||||
|
||||
### Ссылка на задачу в Jira
|
||||
|
||||
```
|
||||
{jira_url}/browse/{task_key}
|
||||
```
|
||||
|
||||
`jira_url` получается из [конфигурации](config.md): `GET /api/config`.
|
||||
|
||||
### TeamTask
|
||||
|
||||
Расширяет `SprintTask` полями спринта:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 101,
|
||||
"task_type": "backend",
|
||||
"task_key": "TS-1234",
|
||||
"task_title": "Пример задачи",
|
||||
"employee_name": "Иван Иванов",
|
||||
"employee_role": "backend",
|
||||
"estimate_seconds": 28800,
|
||||
"total_time_seconds": 32400,
|
||||
"take_in_work_datetime": "2026-01-14 10:00:00+04",
|
||||
"resolution_datetime": "2026-01-20 16:30:00+04",
|
||||
"lead_time_days": 5,
|
||||
"recast_percentage": 12.5,
|
||||
"efficiency_percentage": 45.0,
|
||||
"review_count": 2,
|
||||
"test_count": 1,
|
||||
"sprint_id": 42,
|
||||
"sprint_name": "TS 2026_1 до 26.01"
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `sprint_id` | `integer` | Идентификатор [спринта](sprints.md) |
|
||||
| `sprint_name` | `string` | Название спринта |
|
||||
|
||||
Остальные поля совпадают с [SprintTask](#sprinttask).
|
||||
|
||||
### PaginatedTeamTasks
|
||||
|
||||
Ответ эндпоинта `GET /api/teams/{teamId}/tasks`:
|
||||
|
||||
```json
|
||||
{
|
||||
"team_id": 1,
|
||||
"team_name": "ts",
|
||||
"items": [],
|
||||
"page": 1,
|
||||
"per_page": 50,
|
||||
"total": 128,
|
||||
"total_pages": 3
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `team_id` | `integer` | Идентификатор [команды](teams.md) |
|
||||
| `team_name` | `string` | Название команды |
|
||||
| `items` | `TeamTask[]` | Задачи на текущей странице |
|
||||
| `page` | `integer` | Номер текущей страницы (начиная с 1) |
|
||||
| `per_page` | `integer` | Размер страницы |
|
||||
| `total` | `integer` | Общее количество задач команды |
|
||||
| `total_pages` | `integer` | Общее количество страниц |
|
||||
|
||||
---
|
||||
|
||||
## Эндпоинты
|
||||
|
||||
### `GET /api/teams/{teamId}/tasks`
|
||||
|
||||
Возвращает все задачи [команды](teams.md) из всех её [спринтов](sprints.md) с пагинацией.
|
||||
|
||||
**Параметры пути**
|
||||
|
||||
| Параметр | Тип | Описание |
|
||||
|----------|-----|----------|
|
||||
| `teamId` | `integer` | Идентификатор команды |
|
||||
|
||||
**Query-параметры**
|
||||
|
||||
| Параметр | Тип | По умолчанию | Описание |
|
||||
|----------|-----|--------------|----------|
|
||||
| `page` | `integer` | `1` | Номер страницы (минимум 1) |
|
||||
| `per_page` | `integer` | `50` | Количество задач на странице (1–100) |
|
||||
|
||||
**Ответ:** `200` — объект `PaginatedTeamTasks`
|
||||
|
||||
Задачи отсортированы по дате закрытия (новые первыми), затем по ключу задачи. В выборку попадают только задачи, привязанные к спринтам команды (`tasks.sprint_id` → `sprints.team_id`).
|
||||
|
||||
Если запрошенная страница больше `total_pages`, сервер возвращает последнюю доступную страницу.
|
||||
|
||||
**Ошибки:** `404` — команда не найдена
|
||||
|
||||
**Примеры**
|
||||
|
||||
```bash
|
||||
curl http://localhost/api/teams/1/tasks
|
||||
curl "http://localhost/api/teams/1/tasks?page=2&per_page=50"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/sprints/{sprintId}/tasks`
|
||||
|
||||
Возвращает [задачи](tasks.md), закрытые в указанном [спринте](sprints.md).
|
||||
|
||||
**Параметры пути**
|
||||
|
||||
| Параметр | Тип | Описание |
|
||||
|----------|-----|----------|
|
||||
| `sprintId` | `integer` | Идентификатор спринта |
|
||||
|
||||
**Ответ:** `200` — массив `SprintTask[]`, отсортированный по дате закрытия (от старых к новым), затем по ключу задачи
|
||||
|
||||
**Пример**
|
||||
|
||||
```bash
|
||||
curl http://localhost/api/sprints/42/tasks
|
||||
```
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
# Команды (Team)
|
||||
|
||||
← [Сводка API](README.md)
|
||||
|
||||
Сущность **Team** — команда разработки. Является корневым элементом навигации в интерфейсе.
|
||||
|
||||
## Связанные сущности
|
||||
|
||||
- [Спринты](sprints.md) — спринты команды: `GET /api/teams/{teamId}/sprints`
|
||||
- [Задачи](tasks.md) — все задачи команды: `GET /api/teams/{teamId}/tasks`
|
||||
|
||||
---
|
||||
|
||||
## Модель данных
|
||||
|
||||
### Team
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "ts"
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `id` | `integer` | Идентификатор команды |
|
||||
| `name` | `string` | Название команды (`teams.team_name`, без пробелов по краям) |
|
||||
|
||||
---
|
||||
|
||||
## Эндпоинты
|
||||
|
||||
### `GET /api/teams`
|
||||
|
||||
Возвращает все команды, отсортированные по названию.
|
||||
|
||||
**Ответ:** `200` — массив `Team[]`
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "name": "ts" },
|
||||
{ "id": 4, "name": "bryansk" }
|
||||
]
|
||||
```
|
||||
|
||||
**Пример**
|
||||
|
||||
```bash
|
||||
curl http://localhost/api/teams
|
||||
```
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Сводка по спринтам</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"name": "reports-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchTeams } from './api'
|
||||
import type { Team } from './types'
|
||||
import TeamSidebar from './components/TeamSidebar.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const teams = ref<Team[]>([])
|
||||
const loadingTeams = ref(true)
|
||||
const teamsError = ref<string | null>(null)
|
||||
|
||||
const selectedTeamId = computed(() => {
|
||||
const teamId = Number(route.params.teamId)
|
||||
return Number.isFinite(teamId) && teamId > 0 ? teamId : null
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
teams.value = await fetchTeams()
|
||||
if (route.path === '/' && teams.value.length > 0) {
|
||||
await router.replace(`/teams/${teams.value[0].id}`)
|
||||
}
|
||||
} catch (error) {
|
||||
teamsError.value = error instanceof Error ? error.message : 'Не удалось загрузить команды'
|
||||
} finally {
|
||||
loadingTeams.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.params.teamId,
|
||||
(teamId) => {
|
||||
if (!teamId && teams.value.length > 0 && route.path === '/') {
|
||||
void router.replace(`/teams/${teams.value[0].id}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const selectedTeam = computed(
|
||||
() => teams.value.find((team) => team.id === selectedTeamId.value) ?? null,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div v-if="loadingTeams" class="sidebar__state">Загрузка...</div>
|
||||
<div v-else-if="teamsError" class="sidebar__state sidebar__state--error">{{ teamsError }}</div>
|
||||
<TeamSidebar
|
||||
v-else
|
||||
:teams="teams"
|
||||
:selected-team-id="selectedTeamId"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<header v-if="selectedTeam && route.name === 'team-sprints'" class="main__header">
|
||||
<h2 class="main__title">{{ selectedTeam.name }}</h2>
|
||||
<p class="main__subtitle">Сначала — самые последние спринты</p>
|
||||
</header>
|
||||
|
||||
<RouterView v-if="selectedTeamId !== null" />
|
||||
<div v-else class="main__empty">Выберите команду в сайдбаре</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
min-height: 100vh;
|
||||
background: #23272a;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 0 16px;
|
||||
background: #1e2226;
|
||||
border-right: 0.5px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.sidebar__state {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: rgba(235, 235, 245, 0.45);
|
||||
}
|
||||
|
||||
.sidebar__state--error {
|
||||
color: #ff6961;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 28px 36px;
|
||||
overflow-x: auto;
|
||||
background: #23272a;
|
||||
}
|
||||
|
||||
.main__header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.main__title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: #f5f5f7;
|
||||
}
|
||||
|
||||
.main__subtitle {
|
||||
margin: 0;
|
||||
color: rgba(235, 235, 245, 0.55);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.main__empty {
|
||||
color: rgba(235, 235, 245, 0.45);
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: none;
|
||||
border-bottom: 0.5px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import type { AppConfig, PaginatedTeamTasks, Sprint, SprintTask, Team } from './types'
|
||||
|
||||
async function request<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null)
|
||||
const message = payload?.error ?? `HTTP ${response.status}`
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
export function fetchTeams(): Promise<Team[]> {
|
||||
return request<Team[]>('/api/teams')
|
||||
}
|
||||
|
||||
export function fetchConfig(): Promise<AppConfig> {
|
||||
return request<AppConfig>('/api/config')
|
||||
}
|
||||
|
||||
export function fetchTeamSprints(teamId: number): Promise<Sprint[]> {
|
||||
return request<Sprint[]>(`/api/teams/${teamId}/sprints`)
|
||||
}
|
||||
|
||||
export function fetchSprint(sprintId: number): Promise<Sprint> {
|
||||
return request<Sprint>(`/api/sprints/${sprintId}`)
|
||||
}
|
||||
|
||||
export function fetchSprintTasks(sprintId: number): Promise<SprintTask[]> {
|
||||
return request<SprintTask[]>(`/api/sprints/${sprintId}/tasks`)
|
||||
}
|
||||
|
||||
export function fetchTeamTasks(
|
||||
teamId: number,
|
||||
page = 1,
|
||||
perPage = 50,
|
||||
): Promise<PaginatedTeamTasks> {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(perPage),
|
||||
})
|
||||
|
||||
return request<PaginatedTeamTasks>(`/api/teams/${teamId}/tasks?${params}`)
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
page: number
|
||||
totalPages: number
|
||||
total: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [page: number]
|
||||
}>()
|
||||
|
||||
function goToPage(page: number) {
|
||||
if (page < 1 || page > props.totalPages || page === props.page) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('change', page)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="totalPages > 1" class="pagination">
|
||||
<button
|
||||
type="button"
|
||||
class="pagination__button"
|
||||
:disabled="page <= 1"
|
||||
@click="goToPage(page - 1)"
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<span class="pagination__info">
|
||||
Страница {{ page }} из {{ totalPages }}
|
||||
<span class="pagination__total">({{ total }} задач)</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="pagination__button"
|
||||
:disabled="page >= totalPages"
|
||||
@click="goToPage(page + 1)"
|
||||
>
|
||||
Вперёд
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pagination__button {
|
||||
padding: 8px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #f1f4f7;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.pagination__button:hover:not(:disabled) {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.pagination__button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pagination__info {
|
||||
color: rgba(241, 244, 247, 0.7);
|
||||
font-size: 0.875rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pagination__total {
|
||||
color: rgba(241, 244, 247, 0.45);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,306 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { SprintTask } from '../types'
|
||||
import { UNASSIGNED_ASSIGNEE, getAssigneeKey, getAssigneeLabel } from '../utils/taskFilters'
|
||||
|
||||
const props = defineProps<{
|
||||
tasks: SprintTask[]
|
||||
}>()
|
||||
|
||||
const selectedAssignee = defineModel<string | null>({ default: null })
|
||||
|
||||
const ROLE_ORDER = ['analytic', 'backend', 'frontend', 'designer', 'qa', 'project', '__no_role__', '__unassigned__'] as const
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
analytic: 'Аналитики',
|
||||
backend: 'Backend',
|
||||
frontend: 'Frontend',
|
||||
designer: 'Дизайнеры',
|
||||
qa: 'QA',
|
||||
project: 'Project',
|
||||
__no_role__: 'Без роли',
|
||||
__unassigned__: 'Без исполнителя',
|
||||
}
|
||||
|
||||
function getRoleKey(task: SprintTask): string {
|
||||
if (!task.employee_name) {
|
||||
return '__unassigned__'
|
||||
}
|
||||
|
||||
return task.employee_role ?? '__no_role__'
|
||||
}
|
||||
|
||||
function getRoleLabel(roleKey: string): string {
|
||||
return ROLE_LABELS[roleKey] ?? roleKey
|
||||
}
|
||||
|
||||
const assigneeGroups = computed(() => {
|
||||
const groups = new Map<string, Map<string, number>>()
|
||||
|
||||
for (const task of props.tasks) {
|
||||
const roleKey = getRoleKey(task)
|
||||
const assigneeKey = getAssigneeKey(task)
|
||||
|
||||
if (!groups.has(roleKey)) {
|
||||
groups.set(roleKey, new Map())
|
||||
}
|
||||
|
||||
const assignees = groups.get(roleKey)!
|
||||
assignees.set(assigneeKey, (assignees.get(assigneeKey) ?? 0) + 1)
|
||||
}
|
||||
|
||||
return ROLE_ORDER
|
||||
.filter((roleKey) => groups.has(roleKey))
|
||||
.map((roleKey) => {
|
||||
const assignees = groups.get(roleKey)!
|
||||
const items = [...assignees.entries()]
|
||||
.sort(([leftKey], [rightKey]) => {
|
||||
if (leftKey === UNASSIGNED_ASSIGNEE) {
|
||||
return 1
|
||||
}
|
||||
if (rightKey === UNASSIGNED_ASSIGNEE) {
|
||||
return -1
|
||||
}
|
||||
|
||||
return leftKey.localeCompare(rightKey, 'ru')
|
||||
})
|
||||
.map(([key, count]) => ({
|
||||
key,
|
||||
label: getAssigneeLabel(key),
|
||||
count,
|
||||
}))
|
||||
|
||||
return {
|
||||
roleKey,
|
||||
roleLabel: getRoleLabel(roleKey),
|
||||
assignees: items,
|
||||
total: items.reduce((sum, item) => sum + item.count, 0),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const totalCount = computed(() => props.tasks.length)
|
||||
const collapsed = ref(false)
|
||||
|
||||
const selectedAssigneeLabel = computed(() => {
|
||||
if (selectedAssignee.value === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return getAssigneeLabel(selectedAssignee.value)
|
||||
})
|
||||
|
||||
function toggleCollapsed() {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
|
||||
function selectAssignee(key: string | null) {
|
||||
if (key === null) {
|
||||
selectedAssignee.value = null
|
||||
return
|
||||
}
|
||||
|
||||
selectedAssignee.value = selectedAssignee.value === key ? null : key
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="assigneeGroups.length > 0" class="assignee-summary">
|
||||
<div class="assignee-summary__header" :class="{ 'assignee-summary__header--collapsed': collapsed }">
|
||||
<button
|
||||
type="button"
|
||||
class="assignee-summary__toggle"
|
||||
:aria-expanded="!collapsed"
|
||||
@click="toggleCollapsed"
|
||||
>
|
||||
<span class="assignee-summary__chevron" :class="{ 'assignee-summary__chevron--collapsed': collapsed }">▾</span>
|
||||
<h3 class="assignee-summary__title">Задачи по исполнителям</h3>
|
||||
<span v-if="collapsed && selectedAssigneeLabel" class="assignee-summary__active-filter">
|
||||
{{ selectedAssigneeLabel }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="assignee-summary__item assignee-summary__item--total"
|
||||
:class="{ 'assignee-summary__item--active': selectedAssignee === null }"
|
||||
@click="selectAssignee(null)"
|
||||
>
|
||||
<span class="assignee-summary__name">Всего</span>
|
||||
<span class="assignee-summary__count">{{ totalCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-show="!collapsed" class="assignee-summary__body">
|
||||
<div v-for="group in assigneeGroups" :key="group.roleKey" class="assignee-summary__group">
|
||||
<div class="assignee-summary__role">
|
||||
<span class="assignee-summary__role-label">{{ group.roleLabel }}</span>
|
||||
<span class="assignee-summary__role-count">{{ group.total }}</span>
|
||||
</div>
|
||||
<div class="assignee-summary__list">
|
||||
<button
|
||||
v-for="item in group.assignees"
|
||||
:key="`${group.roleKey}-${item.key}`"
|
||||
type="button"
|
||||
class="assignee-summary__item"
|
||||
:class="{ 'assignee-summary__item--active': selectedAssignee === item.key }"
|
||||
@click="selectAssignee(item.key)"
|
||||
>
|
||||
<span class="assignee-summary__name">{{ item.label }}</span>
|
||||
<span class="assignee-summary__count">{{ item.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assignee-summary {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.assignee-summary__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.assignee-summary__header--collapsed {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.assignee-summary__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.assignee-summary__toggle:hover .assignee-summary__title {
|
||||
color: #f1f4f7;
|
||||
}
|
||||
|
||||
.assignee-summary__chevron {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1;
|
||||
color: rgba(241, 244, 247, 0.55);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.assignee-summary__chevron--collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.assignee-summary__title {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: rgba(241, 244, 247, 0.7);
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
|
||||
.assignee-summary__active-filter {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(41, 165, 255, 0.12);
|
||||
color: rgba(41, 165, 255, 0.95);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assignee-summary__body {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.assignee-summary__group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.assignee-summary__group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.assignee-summary__role {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.assignee-summary__role-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(241, 244, 247, 0.45);
|
||||
}
|
||||
|
||||
.assignee-summary__role-count {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgba(241, 244, 247, 0.35);
|
||||
}
|
||||
|
||||
.assignee-summary__list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.assignee-summary__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
transition: border-color 0.12s ease, background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.assignee-summary__item:hover {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.assignee-summary__item--active {
|
||||
border-color: rgba(41, 165, 255, 0.5);
|
||||
background: rgba(41, 165, 255, 0.12);
|
||||
}
|
||||
|
||||
.assignee-summary__item--total {
|
||||
border-color: rgba(41, 165, 255, 0.3);
|
||||
}
|
||||
|
||||
.assignee-summary__name {
|
||||
color: rgba(241, 244, 247, 0.7);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.assignee-summary__count {
|
||||
color: #f1f4f7;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.assignee-summary__item--active .assignee-summary__name,
|
||||
.assignee-summary__item--total .assignee-summary__name {
|
||||
color: #f1f4f7;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { formatCompletion } from '../utils/formatters'
|
||||
|
||||
defineProps<{
|
||||
value: number | null
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="completion-badge"
|
||||
:class="{
|
||||
'completion-badge--high': value !== null && value >= 80,
|
||||
'completion-badge--medium': value !== null && value >= 50 && value < 80,
|
||||
'completion-badge--low': value !== null && value < 50,
|
||||
}"
|
||||
>
|
||||
{{ formatCompletion(value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.completion-badge {
|
||||
display: inline-block;
|
||||
min-width: 64px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #30363d;
|
||||
color: #c9d1d9;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.completion-badge--high {
|
||||
background: #23863633;
|
||||
color: #3fb950;
|
||||
}
|
||||
|
||||
.completion-badge--medium {
|
||||
background: #9e6a0333;
|
||||
color: #d29922;
|
||||
}
|
||||
|
||||
.completion-badge--low {
|
||||
background: #da363333;
|
||||
color: #f85149;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
CategoryScale,
|
||||
Chart,
|
||||
Filler,
|
||||
Legend,
|
||||
LineController,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Tooltip,
|
||||
} from 'chart.js'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import type { Sprint } from '../types'
|
||||
|
||||
Chart.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
LineController,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
sprints: Sprint[]
|
||||
}>()
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
let chart: Chart | null = null
|
||||
|
||||
const chartSprints = computed(() =>
|
||||
[...props.sprints].sort(
|
||||
(left, right) =>
|
||||
new Date(left.sprint_activate_datetime).getTime() -
|
||||
new Date(right.sprint_activate_datetime).getTime(),
|
||||
),
|
||||
)
|
||||
|
||||
function shortSprintLabel(name: string): string {
|
||||
const match = name.match(/(\d{4}_\d+)/)
|
||||
return match?.[1] ?? name
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
if (!canvasRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
chart?.destroy()
|
||||
|
||||
const labels = chartSprints.value.map((sprint) => shortSprintLabel(sprint.sprint_name))
|
||||
const data = chartSprints.value.map((sprint) => sprint.sprint_completion_percentage)
|
||||
|
||||
chart = new Chart(canvasRef.value, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Выполнение спринта',
|
||||
data,
|
||||
borderColor: '#29a5ff',
|
||||
backgroundColor: 'rgba(41, 165, 255, 0.12)',
|
||||
pointBackgroundColor: '#29a5ff',
|
||||
pointBorderColor: '#29a5ff',
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
spanGaps: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#1e2226',
|
||||
titleColor: '#f1f4f7',
|
||||
bodyColor: '#f1f4f7',
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
title: (items) => {
|
||||
const index = items[0]?.dataIndex ?? 0
|
||||
return chartSprints.value[index]?.sprint_name ?? ''
|
||||
},
|
||||
label: (item) => {
|
||||
if (item.parsed.y === null) {
|
||||
return 'Нет данных'
|
||||
}
|
||||
|
||||
return `Выполнение: ${item.parsed.y.toFixed(1)}%`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: 'rgba(241, 244, 247, 0.55)',
|
||||
maxRotation: 45,
|
||||
minRotation: 0,
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.06)',
|
||||
},
|
||||
},
|
||||
y: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: {
|
||||
color: 'rgba(241, 244, 247, 0.55)',
|
||||
callback: (value) => `${value}%`,
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.06)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(renderChart)
|
||||
|
||||
watch(chartSprints, renderChart, { deep: true })
|
||||
|
||||
onUnmounted(() => {
|
||||
chart?.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="completion-chart">
|
||||
<h3 class="completion-chart__title">Процент закрытия спринтов</h3>
|
||||
<div class="completion-chart__canvas-wrap">
|
||||
<canvas ref="canvasRef" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.completion-chart {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.completion-chart__title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: rgba(241, 244, 247, 0.7);
|
||||
}
|
||||
|
||||
.completion-chart__canvas-wrap {
|
||||
height: 240px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
metricsOnly?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="!metricsOnly" rowspan="2" class="metrics-table__th-sprint">Спринт</th>
|
||||
<th v-if="!metricsOnly" rowspan="2" class="metrics-table__th-completion">Выполнение</th>
|
||||
<th colspan="5" class="metrics-table__th-group">Lead Time</th>
|
||||
<th colspan="5" class="metrics-table__th-group">Количество заходов на тестирование</th>
|
||||
<th colspan="3" class="metrics-table__th-group">Flow Efficiency</th>
|
||||
<th colspan="5" class="metrics-table__th-group">Переработка</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="metrics-table__th-metric">max</th>
|
||||
<th class="metrics-table__th-metric">min</th>
|
||||
<th class="metrics-table__th-metric">95pp</th>
|
||||
<th class="metrics-table__th-metric">80pp</th>
|
||||
<th class="metrics-table__th-metric">50pp</th>
|
||||
<th class="metrics-table__th-metric">max</th>
|
||||
<th class="metrics-table__th-metric">min</th>
|
||||
<th class="metrics-table__th-metric">95pp</th>
|
||||
<th class="metrics-table__th-metric">80pp</th>
|
||||
<th class="metrics-table__th-metric">50pp</th>
|
||||
<th class="metrics-table__th-metric">min</th>
|
||||
<th class="metrics-table__th-metric">max</th>
|
||||
<th class="metrics-table__th-metric">50pp</th>
|
||||
<th class="metrics-table__th-metric">max</th>
|
||||
<th class="metrics-table__th-metric">min</th>
|
||||
<th class="metrics-table__th-metric">95pp</th>
|
||||
<th class="metrics-table__th-metric">80pp</th>
|
||||
<th class="metrics-table__th-metric">50pp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.metrics-table__th-sprint,
|
||||
.metrics-table__th-completion {
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(241, 244, 247, 0.5);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.metrics-table__th-group {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.metrics-table__th-metric {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { Sprint } from '../types'
|
||||
import SprintCompletionBadge from './SprintCompletionBadge.vue'
|
||||
import {
|
||||
formatDateRange,
|
||||
formatMetricInt,
|
||||
formatMetricPercentile,
|
||||
formatPercent,
|
||||
} from '../utils/formatters'
|
||||
|
||||
defineProps<{
|
||||
sprint: Sprint
|
||||
teamId: number
|
||||
linkSprintName?: boolean
|
||||
metricsOnly?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr>
|
||||
<template v-if="!metricsOnly">
|
||||
<td class="metrics-table__name-cell">
|
||||
<RouterLink
|
||||
v-if="linkSprintName"
|
||||
:to="`/teams/${teamId}/sprints/${sprint.id}`"
|
||||
class="metrics-table__name-link"
|
||||
>
|
||||
{{ sprint.sprint_name }}
|
||||
</RouterLink>
|
||||
<div v-else class="metrics-table__name">{{ sprint.sprint_name }}</div>
|
||||
<div class="metrics-table__dates">
|
||||
{{ formatDateRange(sprint.sprint_activate_datetime, sprint.sprint_complete_datetime) }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<SprintCompletionBadge :value="sprint.sprint_completion_percentage" />
|
||||
</td>
|
||||
</template>
|
||||
<td class="metrics-table__metric">{{ formatMetricInt(sprint.lead_time_max) }}</td>
|
||||
<td class="metrics-table__metric">{{ formatMetricInt(sprint.lead_time_min) }}</td>
|
||||
<td class="metrics-table__metric">{{ formatMetricPercentile(sprint.lead_time_p95) }}</td>
|
||||
<td class="metrics-table__metric">{{ formatMetricPercentile(sprint.lead_time_p80) }}</td>
|
||||
<td class="metrics-table__metric">{{ formatMetricPercentile(sprint.lead_time_p50) }}</td>
|
||||
<td class="metrics-table__metric">{{ formatMetricInt(sprint.test_count_max) }}</td>
|
||||
<td class="metrics-table__metric">{{ formatMetricInt(sprint.test_count_min) }}</td>
|
||||
<td class="metrics-table__metric">{{ formatMetricPercentile(sprint.test_count_p95) }}</td>
|
||||
<td class="metrics-table__metric">{{ formatMetricPercentile(sprint.test_count_p80) }}</td>
|
||||
<td class="metrics-table__metric">{{ formatMetricPercentile(sprint.test_count_p50) }}</td>
|
||||
<td class="metrics-table__metric">{{ formatPercent(sprint.efficiency_min) }}</td>
|
||||
<td class="metrics-table__metric">{{ formatPercent(sprint.efficiency_max) }}</td>
|
||||
<td class="metrics-table__metric">{{ formatPercent(sprint.efficiency_p50) }}</td>
|
||||
<td class="metrics-table__metric">{{ formatPercent(sprint.recast_max) }}</td>
|
||||
<td class="metrics-table__metric">{{ formatPercent(sprint.recast_min) }}</td>
|
||||
<td class="metrics-table__metric">{{ formatPercent(sprint.recast_p95) }}</td>
|
||||
<td class="metrics-table__metric">{{ formatPercent(sprint.recast_p80) }}</td>
|
||||
<td class="metrics-table__metric">{{ formatPercent(sprint.recast_p50) }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.metrics-table__name-cell {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.metrics-table__name,
|
||||
.metrics-table__name-link {
|
||||
color: #f1f4f7;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metrics-table__name-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.metrics-table__name-link:hover {
|
||||
color: #29a5ff;
|
||||
}
|
||||
|
||||
.metrics-table__dates {
|
||||
margin-top: 4px;
|
||||
font-size: 0.8125rem;
|
||||
color: rgba(241, 244, 247, 0.45);
|
||||
}
|
||||
|
||||
.metrics-table__metric {
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #f1f4f7;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { fetchTeamSprints } from '../api'
|
||||
import type { Sprint } from '../types'
|
||||
import SprintCompletionChart from './SprintCompletionChart.vue'
|
||||
import SprintMetricsHead from './SprintMetricsHead.vue'
|
||||
import SprintMetricsRow from './SprintMetricsRow.vue'
|
||||
import './metrics-table.css'
|
||||
|
||||
const props = defineProps<{
|
||||
teamId: number
|
||||
}>()
|
||||
|
||||
const sprints = ref<Sprint[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const sortedSprints = computed(() =>
|
||||
[...sprints.value].sort(
|
||||
(left, right) =>
|
||||
new Date(right.sprint_activate_datetime).getTime() -
|
||||
new Date(left.sprint_activate_datetime).getTime(),
|
||||
),
|
||||
)
|
||||
|
||||
async function loadSprints(teamId: number) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
sprints.value = await fetchTeamSprints(teamId)
|
||||
} catch (loadError) {
|
||||
sprints.value = []
|
||||
error.value = loadError instanceof Error ? loadError.message : 'Не удалось загрузить спринты'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.teamId,
|
||||
(teamId) => {
|
||||
void loadSprints(teamId)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sprint-table">
|
||||
<div v-if="loading" class="sprint-table__state">Загрузка спринтов...</div>
|
||||
<div v-else-if="error" class="sprint-table__state sprint-table__state--error">{{ error }}</div>
|
||||
<div v-else-if="sortedSprints.length === 0" class="sprint-table__state">
|
||||
Для этой команды пока нет данных по спринтам
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<SprintCompletionChart :sprints="sprints" />
|
||||
|
||||
<div class="metrics-table__scroll">
|
||||
<table class="metrics-table">
|
||||
<SprintMetricsHead />
|
||||
<tbody>
|
||||
<SprintMetricsRow
|
||||
v-for="sprint in sortedSprints"
|
||||
:key="sprint.id"
|
||||
:sprint="sprint"
|
||||
:team-id="teamId"
|
||||
link-sprint-name
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sprint-table__state {
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.sprint-table__state--error {
|
||||
color: #ff7b72;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { SprintTask } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
tasks: SprintTask[]
|
||||
}>()
|
||||
|
||||
const selectedType = defineModel<string | null>({ default: null })
|
||||
|
||||
const taskTypeCounts = computed(() => {
|
||||
const counts = new Map<string, number>()
|
||||
|
||||
for (const task of props.tasks) {
|
||||
counts.set(task.task_type, (counts.get(task.task_type) ?? 0) + 1)
|
||||
}
|
||||
|
||||
return [...counts.entries()]
|
||||
.sort(([leftType], [rightType]) => leftType.localeCompare(rightType, 'ru'))
|
||||
.map(([type, count]) => ({ type, count }))
|
||||
})
|
||||
|
||||
const totalCount = computed(() => props.tasks.length)
|
||||
|
||||
function selectType(type: string | null) {
|
||||
if (type === null) {
|
||||
selectedType.value = null
|
||||
return
|
||||
}
|
||||
|
||||
selectedType.value = selectedType.value === type ? null : type
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="taskTypeCounts.length > 0" class="task-type-summary">
|
||||
<h3 class="task-type-summary__title">Задачи по типам</h3>
|
||||
<div class="task-type-summary__list">
|
||||
<button
|
||||
v-for="item in taskTypeCounts"
|
||||
:key="item.type"
|
||||
type="button"
|
||||
class="task-type-summary__item"
|
||||
:class="{ 'task-type-summary__item--active': selectedType === item.type }"
|
||||
@click="selectType(item.type)"
|
||||
>
|
||||
<span class="task-type-summary__type">{{ item.type }}</span>
|
||||
<span class="task-type-summary__count">{{ item.count }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="task-type-summary__item task-type-summary__item--total"
|
||||
:class="{ 'task-type-summary__item--active': selectedType === null }"
|
||||
@click="selectType(null)"
|
||||
>
|
||||
<span class="task-type-summary__type">Всего</span>
|
||||
<span class="task-type-summary__count">{{ totalCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.task-type-summary {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.task-type-summary__title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: rgba(241, 244, 247, 0.7);
|
||||
}
|
||||
|
||||
.task-type-summary__list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-type-summary__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
transition: border-color 0.12s ease, background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.task-type-summary__item:hover {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.task-type-summary__item--active {
|
||||
border-color: rgba(41, 165, 255, 0.5);
|
||||
background: rgba(41, 165, 255, 0.12);
|
||||
}
|
||||
|
||||
.task-type-summary__item--total {
|
||||
border-color: rgba(41, 165, 255, 0.3);
|
||||
}
|
||||
|
||||
.task-type-summary__type {
|
||||
color: rgba(241, 244, 247, 0.7);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.task-type-summary__count {
|
||||
color: #f1f4f7;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.task-type-summary__item--active .task-type-summary__type,
|
||||
.task-type-summary__item--total .task-type-summary__type {
|
||||
color: #f1f4f7;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { SprintTask } from '../types'
|
||||
import {
|
||||
formatDate,
|
||||
formatMetricInt,
|
||||
formatPercent,
|
||||
formatSeconds,
|
||||
} from '../utils/formatters'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tasks: SprintTask[]
|
||||
jiraUrl: string
|
||||
showSprint?: boolean
|
||||
teamId?: number | null
|
||||
}>(),
|
||||
{
|
||||
showSprint: false,
|
||||
teamId: null,
|
||||
},
|
||||
)
|
||||
|
||||
function taskUrl(taskKey: string): string {
|
||||
return `${props.jiraUrl}/browse/${taskKey}`
|
||||
}
|
||||
|
||||
function hasSprint(task: SprintTask): task is SprintTask & { sprint_id: number; sprint_name: string } {
|
||||
return 'sprint_id' in task && 'sprint_name' in task
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tasks-table__scroll">
|
||||
<table class="tasks-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Задача</th>
|
||||
<th>Название</th>
|
||||
<th v-if="showSprint">Спринт</th>
|
||||
<th>Тип</th>
|
||||
<th>Исполнитель</th>
|
||||
<th>Оценка</th>
|
||||
<th>Факт</th>
|
||||
<th>Lead Time</th>
|
||||
<th>Переработка</th>
|
||||
<th>Flow Efficiency</th>
|
||||
<th>Ревью</th>
|
||||
<th>Тест</th>
|
||||
<th>Закрыта</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="task in tasks" :key="task.id">
|
||||
<td class="tasks-table__key">
|
||||
<a
|
||||
:href="taskUrl(task.task_key)"
|
||||
class="tasks-table__key-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ task.task_key }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="tasks-table__title">{{ task.task_title }}</td>
|
||||
<td v-if="showSprint" class="tasks-table__sprint">
|
||||
<RouterLink
|
||||
v-if="hasSprint(task) && teamId"
|
||||
:to="`/teams/${teamId}/sprints/${task.sprint_id}`"
|
||||
class="tasks-table__sprint-link"
|
||||
>
|
||||
{{ task.sprint_name }}
|
||||
</RouterLink>
|
||||
<span v-else-if="hasSprint(task)">{{ task.sprint_name }}</span>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td class="tasks-table__metric">{{ task.task_type }}</td>
|
||||
<td>
|
||||
<div v-if="task.employee_name">{{ task.employee_name }}</div>
|
||||
<div v-if="task.employee_role" class="tasks-table__role">{{ task.employee_role }}</div>
|
||||
<span v-if="!task.employee_name">—</span>
|
||||
</td>
|
||||
<td class="tasks-table__metric">{{ formatSeconds(task.estimate_seconds) }}</td>
|
||||
<td class="tasks-table__metric">{{ formatSeconds(task.total_time_seconds) }}</td>
|
||||
<td class="tasks-table__metric">{{ formatMetricInt(task.lead_time_days) }}</td>
|
||||
<td class="tasks-table__metric">{{ formatPercent(task.recast_percentage) }}</td>
|
||||
<td class="tasks-table__metric">{{ formatPercent(task.efficiency_percentage) }}</td>
|
||||
<td class="tasks-table__metric">{{ task.review_count }}</td>
|
||||
<td class="tasks-table__metric">{{ task.test_count }}</td>
|
||||
<td class="tasks-table__metric">
|
||||
{{ task.resolution_datetime ? formatDate(task.resolution_datetime) : '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tasks-table__scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tasks-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tasks-table th,
|
||||
.tasks-table td {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.tasks-table th {
|
||||
color: rgba(241, 244, 247, 0.5);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.tasks-table__key-link {
|
||||
color: #29a5ff;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tasks-table__key-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tasks-table__title {
|
||||
color: #f1f4f7;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.tasks-table__sprint {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.tasks-table__sprint-link {
|
||||
color: #29a5ff;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tasks-table__sprint-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tasks-table__role {
|
||||
margin-top: 2px;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(241, 244, 247, 0.45);
|
||||
}
|
||||
|
||||
.tasks-table__metric {
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #f1f4f7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { Team } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
teams: Team[]
|
||||
selectedTeamId: number | null
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
function isSprintsActive(teamId: number): boolean {
|
||||
return props.selectedTeamId === teamId && route.name !== 'team-ltd'
|
||||
}
|
||||
|
||||
function isLtdActive(teamId: number): boolean {
|
||||
return props.selectedTeamId === teamId && route.name === 'team-ltd'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="sidebar-nav">
|
||||
<section v-for="team in teams" :key="team.id" class="sidebar-nav__group">
|
||||
<h2 class="sidebar-nav__group-title">{{ team.name }}</h2>
|
||||
<RouterLink
|
||||
:to="`/teams/${team.id}`"
|
||||
class="sidebar-nav__link"
|
||||
:class="{ 'sidebar-nav__link--active': isSprintsActive(team.id) }"
|
||||
>
|
||||
Спринты
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
:to="`/teams/${team.id}/ltd`"
|
||||
class="sidebar-nav__link"
|
||||
:class="{ 'sidebar-nav__link--active': isLtdActive(team.id) }"
|
||||
>
|
||||
LTD
|
||||
</RouterLink>
|
||||
</section>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.sidebar-nav__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sidebar-nav__group-title {
|
||||
margin: 0;
|
||||
padding: 4px 12px 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: rgba(241, 244, 247, 0.85);
|
||||
}
|
||||
|
||||
.sidebar-nav__link {
|
||||
display: block;
|
||||
padding: 7px 12px 7px 24px;
|
||||
border-radius: 12px;
|
||||
color: rgba(241, 244, 247, 0.7);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.sidebar-nav__link:hover {
|
||||
color: #f1f4f7;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.sidebar-nav__link--active {
|
||||
background: #2b2e30;
|
||||
color: #29a5ff;
|
||||
}
|
||||
|
||||
.sidebar-nav__link--active:hover {
|
||||
background: #2b2e30;
|
||||
color: #29a5ff;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
.metrics-table {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.metrics-table th,
|
||||
.metrics-table td {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.metrics-table th {
|
||||
color: #f1f4f7;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.metrics-table__scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import SprintDetailView from './views/SprintDetailView.vue'
|
||||
import TeamLtdView from './views/TeamLtdView.vue'
|
||||
import TeamSprintsView from './views/TeamSprintsView.vue'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: () => '/teams/1',
|
||||
},
|
||||
{
|
||||
path: '/teams/:teamId',
|
||||
name: 'team-sprints',
|
||||
component: TeamSprintsView,
|
||||
},
|
||||
{
|
||||
path: '/teams/:teamId/ltd',
|
||||
name: 'team-ltd',
|
||||
component: TeamLtdView,
|
||||
},
|
||||
{
|
||||
path: '/teams/:teamId/sprints/:sprintId',
|
||||
name: 'sprint-detail',
|
||||
component: SprintDetailView,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', system-ui, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color: #f1f4f7;
|
||||
background-color: #23272a;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
export interface Team {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
jira_url: string
|
||||
}
|
||||
|
||||
export interface Sprint {
|
||||
id: number
|
||||
team_id: number
|
||||
team_name: string
|
||||
jira_sprint_id: number
|
||||
sprint_name: string
|
||||
sprint_activate_datetime: string
|
||||
sprint_complete_datetime: string
|
||||
sprint_completion_percentage: number | null
|
||||
lead_time_max: number | null
|
||||
lead_time_min: number | null
|
||||
lead_time_p95: number | null
|
||||
lead_time_p80: number | null
|
||||
lead_time_p50: number | null
|
||||
test_count_max: number | null
|
||||
test_count_min: number | null
|
||||
test_count_p95: number | null
|
||||
test_count_p80: number | null
|
||||
test_count_p50: number | null
|
||||
efficiency_min: number | null
|
||||
efficiency_max: number | null
|
||||
efficiency_p50: number | null
|
||||
recast_max: number | null
|
||||
recast_min: number | null
|
||||
recast_p95: number | null
|
||||
recast_p80: number | null
|
||||
recast_p50: number | null
|
||||
}
|
||||
|
||||
export interface SprintTask {
|
||||
id: number
|
||||
task_type: string
|
||||
task_key: string
|
||||
task_title: string
|
||||
employee_name: string | null
|
||||
employee_role: string | null
|
||||
estimate_seconds: number
|
||||
total_time_seconds: number
|
||||
take_in_work_datetime: string | null
|
||||
resolution_datetime: string | null
|
||||
lead_time_days: number | null
|
||||
recast_percentage: number | null
|
||||
efficiency_percentage: number | null
|
||||
review_count: number
|
||||
test_count: number
|
||||
}
|
||||
|
||||
export interface TeamTask extends SprintTask {
|
||||
sprint_id: number
|
||||
sprint_name: string
|
||||
}
|
||||
|
||||
export interface PaginatedTeamTasks {
|
||||
team_id: number
|
||||
team_name: string
|
||||
items: TeamTask[]
|
||||
page: number
|
||||
per_page: number
|
||||
total: number
|
||||
total_pages: number
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
export function formatDate(value: string): string {
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
export function formatDateRange(start: string, end: string): string {
|
||||
return `${formatDate(start)} — ${formatDate(end)}`
|
||||
}
|
||||
|
||||
export function formatCompletion(value: number | null): string {
|
||||
if (value === null) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
return `${value.toFixed(1)}%`
|
||||
}
|
||||
|
||||
export function formatMetricInt(value: number | null): string {
|
||||
if (value === null) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function formatMetricPercentile(value: number | null): string {
|
||||
if (value === null) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
return value.toFixed(1)
|
||||
}
|
||||
|
||||
export function formatPercent(value: number | null): string {
|
||||
if (value === null) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
return `${value.toFixed(1)}%`
|
||||
}
|
||||
|
||||
export function formatSeconds(value: number): string {
|
||||
const hours = Math.floor(value / 3600)
|
||||
const minutes = Math.floor((value % 3600) / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return minutes > 0 ? `${hours}ч ${minutes}м` : `${hours}ч`
|
||||
}
|
||||
|
||||
return `${minutes}м`
|
||||
}
|
||||
|
||||
export function formatDateTime(value: string | null): string {
|
||||
if (value === null) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import type { SprintTask } from '../types'
|
||||
|
||||
export const UNASSIGNED_ASSIGNEE = '__unassigned__'
|
||||
|
||||
export function getAssigneeKey(task: SprintTask): string {
|
||||
return task.employee_name ?? UNASSIGNED_ASSIGNEE
|
||||
}
|
||||
|
||||
export function getAssigneeLabel(key: string): string {
|
||||
return key === UNASSIGNED_ASSIGNEE ? 'Без исполнителя' : key
|
||||
}
|
||||
|
||||
export function matchesAssigneeFilter(task: SprintTask, selectedAssignee: string | null): boolean {
|
||||
if (selectedAssignee === null) {
|
||||
return true
|
||||
}
|
||||
|
||||
return getAssigneeKey(task) === selectedAssignee
|
||||
}
|
||||
|
||||
export function matchesTaskTypeFilter(task: SprintTask, selectedTaskType: string | null): boolean {
|
||||
if (selectedTaskType === null) {
|
||||
return true
|
||||
}
|
||||
|
||||
return task.task_type === selectedTaskType
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { fetchConfig, fetchSprint, fetchSprintTasks } from '../api'
|
||||
import type { Sprint, SprintTask } from '../types'
|
||||
import SprintCompletionBadge from '../components/SprintCompletionBadge.vue'
|
||||
import SprintMetricsHead from '../components/SprintMetricsHead.vue'
|
||||
import SprintMetricsRow from '../components/SprintMetricsRow.vue'
|
||||
import SprintAssigneeSummary from '../components/SprintAssigneeSummary.vue'
|
||||
import SprintTaskTypeSummary from '../components/SprintTaskTypeSummary.vue'
|
||||
import SprintTasksTable from '../components/SprintTasksTable.vue'
|
||||
import { formatDateRange } from '../utils/formatters'
|
||||
import {
|
||||
getAssigneeLabel,
|
||||
matchesAssigneeFilter,
|
||||
matchesTaskTypeFilter,
|
||||
} from '../utils/taskFilters'
|
||||
import '../components/metrics-table.css'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const sprint = ref<Sprint | null>(null)
|
||||
const tasks = ref<SprintTask[]>([])
|
||||
const jiraUrl = ref('')
|
||||
const selectedTaskType = ref<string | null>(null)
|
||||
const selectedAssignee = ref<string | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const teamId = () => Number(route.params.teamId)
|
||||
const sprintId = () => Number(route.params.sprintId)
|
||||
|
||||
const filteredTasks = computed(() =>
|
||||
tasks.value.filter(
|
||||
(task) =>
|
||||
matchesTaskTypeFilter(task, selectedTaskType.value) &&
|
||||
matchesAssigneeFilter(task, selectedAssignee.value),
|
||||
),
|
||||
)
|
||||
|
||||
const activeFilterHints = computed(() => {
|
||||
const hints: string[] = []
|
||||
|
||||
if (selectedTaskType.value) {
|
||||
hints.push(selectedTaskType.value)
|
||||
}
|
||||
|
||||
if (selectedAssignee.value) {
|
||||
hints.push(getAssigneeLabel(selectedAssignee.value))
|
||||
}
|
||||
|
||||
return hints
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(() => activeFilterHints.value.length > 0)
|
||||
|
||||
async function loadSprintDetail() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
selectedTaskType.value = null
|
||||
selectedAssignee.value = null
|
||||
|
||||
try {
|
||||
const [sprintData, tasksData, config] = await Promise.all([
|
||||
fetchSprint(sprintId()),
|
||||
fetchSprintTasks(sprintId()),
|
||||
fetchConfig(),
|
||||
])
|
||||
sprint.value = sprintData
|
||||
tasks.value = tasksData
|
||||
jiraUrl.value = config.jira_url
|
||||
} catch (loadError) {
|
||||
sprint.value = null
|
||||
tasks.value = []
|
||||
error.value = loadError instanceof Error ? loadError.message : 'Не удалось загрузить спринт'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadSprintDetail()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.params.sprintId,
|
||||
() => {
|
||||
void loadSprintDetail()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sprint-detail">
|
||||
<RouterLink :to="`/teams/${teamId()}`" class="sprint-detail__back">
|
||||
← К списку спринтов
|
||||
</RouterLink>
|
||||
|
||||
<div v-if="loading" class="sprint-detail__state">Загрузка спринта...</div>
|
||||
<div v-else-if="error" class="sprint-detail__state sprint-detail__state--error">{{ error }}</div>
|
||||
<div v-else-if="sprint">
|
||||
<header class="sprint-detail__header">
|
||||
<div class="sprint-detail__title-row">
|
||||
<h2 class="sprint-detail__title">{{ sprint.sprint_name }}</h2>
|
||||
<SprintCompletionBadge :value="sprint.sprint_completion_percentage" />
|
||||
</div>
|
||||
<p class="sprint-detail__subtitle">
|
||||
{{ sprint.team_name }} ·
|
||||
{{ formatDateRange(sprint.sprint_activate_datetime, sprint.sprint_complete_datetime) }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="sprint-detail__section">
|
||||
<div class="metrics-table__scroll">
|
||||
<table class="metrics-table">
|
||||
<SprintMetricsHead metrics-only />
|
||||
<tbody>
|
||||
<SprintMetricsRow :sprint="sprint" :team-id="teamId()" metrics-only />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<SprintTaskTypeSummary v-model="selectedTaskType" :tasks="tasks" />
|
||||
<SprintAssigneeSummary v-model="selectedAssignee" :tasks="tasks" />
|
||||
</section>
|
||||
|
||||
<section class="sprint-detail__section">
|
||||
<h3 class="sprint-detail__section-title">
|
||||
Задачи, закрытые в спринте
|
||||
<span v-if="hasActiveFilters" class="sprint-detail__filter-hint">
|
||||
· {{ activeFilterHints.join(' · ') }}
|
||||
</span>
|
||||
</h3>
|
||||
<div v-if="tasks.length === 0" class="sprint-detail__state">
|
||||
Нет задач, закрытых в этом спринте
|
||||
</div>
|
||||
<div v-else-if="filteredTasks.length === 0" class="sprint-detail__state">
|
||||
Нет задач по выбранным фильтрам
|
||||
</div>
|
||||
<SprintTasksTable v-else :tasks="filteredTasks" :jira-url="jiraUrl" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sprint-detail__back {
|
||||
display: inline-block;
|
||||
margin-bottom: 20px;
|
||||
color: #29a5ff;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.sprint-detail__back:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.sprint-detail__header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.sprint-detail__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sprint-detail__title {
|
||||
margin: 0;
|
||||
font-size: 1.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: #f5f5f7;
|
||||
}
|
||||
|
||||
.sprint-detail__subtitle {
|
||||
margin: 6px 0 0;
|
||||
color: rgba(235, 235, 245, 0.55);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.sprint-detail__section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.sprint-detail__section-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: rgba(241, 244, 247, 0.7);
|
||||
}
|
||||
|
||||
.sprint-detail__filter-hint {
|
||||
color: #29a5ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sprint-detail__state {
|
||||
padding: 32px 0;
|
||||
text-align: center;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.sprint-detail__state--error {
|
||||
color: #ff7b72;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchConfig, fetchTeamTasks } from '../api'
|
||||
import type { TeamTask } from '../types'
|
||||
import Pagination from '../components/Pagination.vue'
|
||||
import SprintTasksTable from '../components/SprintTasksTable.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tasks = ref<TeamTask[]>([])
|
||||
const teamName = ref('')
|
||||
const jiraUrl = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(0)
|
||||
const total = ref(0)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const teamId = computed(() => Number(route.params.teamId))
|
||||
|
||||
function currentPageFromRoute(): number {
|
||||
const value = Number(route.query.page ?? 1)
|
||||
return Number.isFinite(value) && value > 0 ? value : 1
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
page.value = currentPageFromRoute()
|
||||
|
||||
try {
|
||||
const [config, result] = await Promise.all([
|
||||
jiraUrl.value ? Promise.resolve({ jira_url: jiraUrl.value }) : fetchConfig(),
|
||||
fetchTeamTasks(teamId.value, page.value),
|
||||
])
|
||||
|
||||
jiraUrl.value = config.jira_url
|
||||
tasks.value = result.items
|
||||
teamName.value = result.team_name
|
||||
page.value = result.page
|
||||
totalPages.value = result.total_pages
|
||||
total.value = result.total
|
||||
|
||||
if (result.page !== currentPageFromRoute()) {
|
||||
await router.replace({
|
||||
query: result.page > 1 ? { page: String(result.page) } : {},
|
||||
})
|
||||
}
|
||||
} catch (loadError) {
|
||||
error.value = loadError instanceof Error ? loadError.message : 'Не удалось загрузить задачи'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changePage(nextPage: number) {
|
||||
void router.push({
|
||||
query: nextPage > 1 ? { page: String(nextPage) } : {},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
|
||||
watch(
|
||||
() => [route.params.teamId, route.query.page],
|
||||
() => {
|
||||
void loadData()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="team-ltd">
|
||||
<header class="team-ltd__header">
|
||||
<h2 class="team-ltd__title">{{ teamName || 'LTD' }}</h2>
|
||||
<p class="team-ltd__subtitle">Все задачи команды</p>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="team-ltd__state">Загрузка...</div>
|
||||
<div v-else-if="error" class="team-ltd__state team-ltd__state--error">{{ error }}</div>
|
||||
<div v-else-if="tasks.length === 0" class="team-ltd__state">Задач пока нет</div>
|
||||
<template v-else>
|
||||
<SprintTasksTable
|
||||
:tasks="tasks"
|
||||
:jira-url="jiraUrl"
|
||||
:team-id="teamId"
|
||||
show-sprint
|
||||
/>
|
||||
<Pagination
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
:total="total"
|
||||
@change="changePage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.team-ltd__header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.team-ltd__title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: #f5f5f7;
|
||||
}
|
||||
|
||||
.team-ltd__subtitle {
|
||||
margin: 0;
|
||||
color: rgba(235, 235, 245, 0.55);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.team-ltd__state {
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
color: rgba(235, 235, 245, 0.45);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.team-ltd__state--error {
|
||||
color: #ff6961;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import SprintTable from '../components/SprintTable.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const teamId = computed(() => Number(route.params.teamId))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SprintTable :team-id="teamId" />
|
||||
</template>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.d.ts"]
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"root":["./src/api.ts","./src/main.ts","./src/router.ts","./src/types.ts","./src/vite-env.d.ts","./src/utils/formatters.ts","./src/utils/taskfilters.ts","./src/app.vue","./src/components/pagination.vue","./src/components/sprintassigneesummary.vue","./src/components/sprintcompletionbadge.vue","./src/components/sprintcompletionchart.vue","./src/components/sprintmetricshead.vue","./src/components/sprintmetricsrow.vue","./src/components/sprinttable.vue","./src/components/sprinttasktypesummary.vue","./src/components/sprinttaskstable.vue","./src/components/teamsidebar.vue","./src/views/sprintdetailview.vue","./src/views/teamltdview.vue","./src/views/teamsprintsview.vue"],"version":"5.7.3"}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
use Nechaev\Reports\Features\Reports\ReportsApi;
|
||||
|
||||
require_once __DIR__ . '/../init.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
|
||||
if ($method === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$api = new ReportsApi();
|
||||
|
||||
if ($method === 'GET' && $path === '/api/teams') {
|
||||
echo json_encode($api->getTeams(), JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($method === 'GET' && $path === '/api/config') {
|
||||
echo json_encode($api->getPublicConfig(), JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($method === 'GET' && preg_match('#^/api/teams/(\d+)/sprints$#', $path, $matches)) {
|
||||
echo json_encode($api->getSprintsByTeamId((int) $matches[1]), JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($method === 'GET' && preg_match('#^/api/teams/(\d+)/tasks$#', $path, $matches)) {
|
||||
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||
$perPage = max(1, min(100, (int) ($_GET['per_page'] ?? 50)));
|
||||
$result = $api->getTasksByTeamId((int) $matches[1], $page, $perPage);
|
||||
if ($result === null) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Team not found'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
echo json_encode($result, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($method === 'GET' && preg_match('#^/api/sprints/(\d+)/tasks$#', $path, $matches)) {
|
||||
echo json_encode($api->getTasksBySprintId((int) $matches[1]), JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($method === 'GET' && preg_match('#^/api/sprints/(\d+)$#', $path, $matches)) {
|
||||
$sprint = $api->getSprintById((int) $matches[1]);
|
||||
if ($sprint === null) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Sprint not found'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
echo json_encode($sprint, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Not found'], JSON_UNESCAPED_UNICODE);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
$file = __DIR__ . $path;
|
||||
|
||||
if ($path !== '/' && is_file($file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
require __DIR__ . '/index.php';
|
||||
|
|
@ -1,343 +0,0 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Nechaev\Reports\Features\Reports;
|
||||
|
||||
use Nechaev\Reports\Core\App;
|
||||
use Nechaev\Reports\Framework\Database\Database;
|
||||
|
||||
final class ReportsApi {
|
||||
private Database $db;
|
||||
|
||||
public function __construct(?Database $db = null) {
|
||||
$this->db = $db ?? App::getDb();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id: int, name: string}>
|
||||
*/
|
||||
public function getTeams(): array {
|
||||
$rows = $this->db->getRowset(
|
||||
'SELECT id, TRIM(team_name) AS name FROM teams ORDER BY TRIM(team_name)'
|
||||
);
|
||||
|
||||
return array_map(
|
||||
static fn (array $row): array => [
|
||||
'id' => (int) $row['id'],
|
||||
'name' => $row['name'],
|
||||
],
|
||||
$rows
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getSprintsByTeamId(int $teamId): array {
|
||||
$rows = $this->db->getRowset(
|
||||
$this->buildSprintMetricsSql(
|
||||
'WHERE s.team_id = :team_id',
|
||||
'ORDER BY s.sprint_activate_datetime ASC'
|
||||
),
|
||||
['team_id' => $teamId]
|
||||
);
|
||||
|
||||
return array_map(fn (array $row): array => $this->mapSprintRow($row), $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getSprintById(int $sprintId): ?array {
|
||||
$rows = $this->db->getRowset(
|
||||
$this->buildSprintMetricsSql('WHERE s.id = :sprint_id'),
|
||||
['sprint_id' => $sprintId]
|
||||
);
|
||||
|
||||
if ($rows === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->mapSprintRow($rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{jira_url: string}
|
||||
*/
|
||||
public function getPublicConfig(): array {
|
||||
return [
|
||||
'jira_url' => rtrim(App::getConfig()->jira_url, '/'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* team_id: int,
|
||||
* team_name: string,
|
||||
* items: array<int, array<string, mixed>>,
|
||||
* page: int,
|
||||
* per_page: int,
|
||||
* total: int,
|
||||
* total_pages: int
|
||||
* }|null
|
||||
*/
|
||||
public function getTasksByTeamId(int $teamId, int $page = 1, int $perPage = 50): ?array {
|
||||
$teamRows = $this->db->getRowset(
|
||||
'SELECT id, TRIM(team_name) AS team_name FROM teams WHERE id = :team_id',
|
||||
['team_id' => $teamId]
|
||||
);
|
||||
|
||||
if ($teamRows === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$page = max(1, $page);
|
||||
$perPage = max(1, min(100, $perPage));
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$countRows = $this->db->getRowset(
|
||||
'SELECT COUNT(*) AS total
|
||||
FROM tasks t
|
||||
JOIN sprints s ON s.id = t.sprint_id
|
||||
WHERE s.team_id = :team_id',
|
||||
['team_id' => $teamId]
|
||||
);
|
||||
$total = (int) $countRows[0]['total'];
|
||||
$totalPages = $perPage > 0 ? (int) ceil($total / $perPage) : 0;
|
||||
|
||||
if ($totalPages > 0 && $page > $totalPages) {
|
||||
$page = $totalPages;
|
||||
$offset = ($page - 1) * $perPage;
|
||||
}
|
||||
|
||||
$rows = $this->db->getRowset(
|
||||
'SELECT t.id,
|
||||
t.task_type,
|
||||
t.task_key,
|
||||
t.task_title,
|
||||
e.employee_name,
|
||||
e.employee_role::text AS employee_role,
|
||||
t.estimate_seconds,
|
||||
t.total_time_seconds,
|
||||
t.take_in_work_datetime,
|
||||
t.resolution_datetime,
|
||||
t.lead_time_days,
|
||||
t.recast_percentage,
|
||||
t.efficiency_percentage,
|
||||
t.review_count,
|
||||
t.test_count,
|
||||
s.id AS sprint_id,
|
||||
s.sprint_name
|
||||
FROM tasks t
|
||||
JOIN sprints s ON s.id = t.sprint_id
|
||||
LEFT JOIN employees e ON e.id = t.employee_id
|
||||
WHERE s.team_id = :team_id
|
||||
ORDER BY t.resolution_datetime DESC NULLS LAST, t.task_key ASC
|
||||
LIMIT :limit OFFSET :offset',
|
||||
[
|
||||
'team_id' => $teamId,
|
||||
'limit' => $perPage,
|
||||
'offset' => $offset,
|
||||
]
|
||||
);
|
||||
|
||||
return [
|
||||
'team_id' => (int) $teamRows[0]['id'],
|
||||
'team_name' => trim((string) $teamRows[0]['team_name']),
|
||||
'items' => array_map(
|
||||
fn (array $row): array => array_merge(
|
||||
$this->mapTaskRow($row),
|
||||
[
|
||||
'sprint_id' => (int) $row['sprint_id'],
|
||||
'sprint_name' => $row['sprint_name'],
|
||||
]
|
||||
),
|
||||
$rows
|
||||
),
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
'total_pages' => $totalPages,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{
|
||||
* id: int,
|
||||
* task_type: string,
|
||||
* task_key: string,
|
||||
* task_title: string,
|
||||
* employee_name: string|null,
|
||||
* employee_role: string|null,
|
||||
* estimate_seconds: int,
|
||||
* total_time_seconds: int,
|
||||
* take_in_work_datetime: string|null,
|
||||
* resolution_datetime: string|null,
|
||||
* lead_time_days: int|null,
|
||||
* recast_percentage: float|null,
|
||||
* efficiency_percentage: float|null,
|
||||
* review_count: int,
|
||||
* test_count: int
|
||||
* }>
|
||||
*/
|
||||
public function getTasksBySprintId(int $sprintId): array {
|
||||
$rows = $this->db->getRowset(
|
||||
'SELECT t.id,
|
||||
t.task_type,
|
||||
t.task_key,
|
||||
t.task_title,
|
||||
e.employee_name,
|
||||
e.employee_role::text AS employee_role,
|
||||
t.estimate_seconds,
|
||||
t.total_time_seconds,
|
||||
t.take_in_work_datetime,
|
||||
t.resolution_datetime,
|
||||
t.lead_time_days,
|
||||
t.recast_percentage,
|
||||
t.efficiency_percentage,
|
||||
t.review_count,
|
||||
t.test_count
|
||||
FROM tasks t
|
||||
LEFT JOIN employees e ON e.id = t.employee_id
|
||||
WHERE t.sprint_id = :sprint_id
|
||||
ORDER BY t.resolution_datetime ASC NULLS LAST, t.task_key ASC',
|
||||
['sprint_id' => $sprintId]
|
||||
);
|
||||
|
||||
return array_map(
|
||||
fn (array $row): array => $this->mapTaskRow($row),
|
||||
$rows
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array{
|
||||
* id: int,
|
||||
* task_type: string,
|
||||
* task_key: string,
|
||||
* task_title: string,
|
||||
* employee_name: string|null,
|
||||
* employee_role: string|null,
|
||||
* estimate_seconds: int,
|
||||
* total_time_seconds: int,
|
||||
* take_in_work_datetime: string|null,
|
||||
* resolution_datetime: string|null,
|
||||
* lead_time_days: int|null,
|
||||
* recast_percentage: float|null,
|
||||
* efficiency_percentage: float|null,
|
||||
* review_count: int,
|
||||
* test_count: int
|
||||
* }
|
||||
*/
|
||||
private function mapTaskRow(array $row): array {
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'task_type' => $row['task_type'],
|
||||
'task_key' => $row['task_key'],
|
||||
'task_title' => $row['task_title'],
|
||||
'employee_name' => $row['employee_name'],
|
||||
'employee_role' => $row['employee_role'],
|
||||
'estimate_seconds' => (int) $row['estimate_seconds'],
|
||||
'total_time_seconds' => (int) $row['total_time_seconds'],
|
||||
'take_in_work_datetime' => $row['take_in_work_datetime'],
|
||||
'resolution_datetime' => $row['resolution_datetime'],
|
||||
'lead_time_days' => $row['lead_time_days'] !== null ? (int) $row['lead_time_days'] : null,
|
||||
'recast_percentage' => $row['recast_percentage'] !== null ? (float) $row['recast_percentage'] : null,
|
||||
'efficiency_percentage' => $row['efficiency_percentage'] !== null
|
||||
? (float) $row['efficiency_percentage']
|
||||
: null,
|
||||
'review_count' => (int) $row['review_count'],
|
||||
'test_count' => (int) $row['test_count'],
|
||||
];
|
||||
}
|
||||
|
||||
private function buildSprintMetricsSql(string $where, string $orderBy = ''): string {
|
||||
return 'SELECT s.id,
|
||||
s.team_id,
|
||||
TRIM(tm.team_name) AS team_name,
|
||||
s.jira_sprint_id,
|
||||
s.sprint_name,
|
||||
s.sprint_activate_datetime,
|
||||
s.sprint_complete_datetime,
|
||||
s.sprint_completion_percentage,
|
||||
MAX(t.lead_time_days) FILTER (WHERE t.lead_time_days IS NOT NULL) AS lead_time_max,
|
||||
MIN(t.lead_time_days) FILTER (WHERE t.lead_time_days IS NOT NULL) AS lead_time_min,
|
||||
percentile_cont(0.95) WITHIN GROUP (ORDER BY t.lead_time_days)
|
||||
FILTER (WHERE t.lead_time_days IS NOT NULL) AS lead_time_p95,
|
||||
percentile_cont(0.80) WITHIN GROUP (ORDER BY t.lead_time_days)
|
||||
FILTER (WHERE t.lead_time_days IS NOT NULL) AS lead_time_p80,
|
||||
percentile_cont(0.50) WITHIN GROUP (ORDER BY t.lead_time_days)
|
||||
FILTER (WHERE t.lead_time_days IS NOT NULL) AS lead_time_p50,
|
||||
MAX(t.test_count) FILTER (WHERE e.employee_role IN (\'backend\', \'frontend\')) AS test_count_max,
|
||||
MIN(t.test_count) FILTER (WHERE e.employee_role IN (\'backend\', \'frontend\')) AS test_count_min,
|
||||
percentile_cont(0.95) WITHIN GROUP (ORDER BY t.test_count)
|
||||
FILTER (WHERE e.employee_role IN (\'backend\', \'frontend\')) AS test_count_p95,
|
||||
percentile_cont(0.80) WITHIN GROUP (ORDER BY t.test_count)
|
||||
FILTER (WHERE e.employee_role IN (\'backend\', \'frontend\')) AS test_count_p80,
|
||||
percentile_cont(0.50) WITHIN GROUP (ORDER BY t.test_count)
|
||||
FILTER (WHERE e.employee_role IN (\'backend\', \'frontend\')) AS test_count_p50,
|
||||
MIN(t.efficiency_percentage) FILTER (WHERE t.efficiency_percentage IS NOT NULL) AS efficiency_min,
|
||||
MAX(t.efficiency_percentage) FILTER (WHERE t.efficiency_percentage IS NOT NULL) AS efficiency_max,
|
||||
percentile_cont(0.50) WITHIN GROUP (ORDER BY t.efficiency_percentage)
|
||||
FILTER (WHERE t.efficiency_percentage IS NOT NULL) AS efficiency_p50,
|
||||
MAX(t.recast_percentage) FILTER (WHERE t.recast_percentage IS NOT NULL) AS recast_max,
|
||||
MIN(t.recast_percentage) FILTER (WHERE t.recast_percentage IS NOT NULL) AS recast_min,
|
||||
percentile_cont(0.95) WITHIN GROUP (ORDER BY t.recast_percentage)
|
||||
FILTER (WHERE t.recast_percentage IS NOT NULL) AS recast_p95,
|
||||
percentile_cont(0.80) WITHIN GROUP (ORDER BY t.recast_percentage)
|
||||
FILTER (WHERE t.recast_percentage IS NOT NULL) AS recast_p80,
|
||||
percentile_cont(0.50) WITHIN GROUP (ORDER BY t.recast_percentage)
|
||||
FILTER (WHERE t.recast_percentage IS NOT NULL) AS recast_p50
|
||||
FROM sprints s
|
||||
JOIN teams tm ON tm.id = s.team_id
|
||||
LEFT JOIN tasks t ON t.sprint_id = s.id
|
||||
LEFT JOIN employees e ON e.id = t.employee_id
|
||||
' . $where . '
|
||||
GROUP BY s.id,
|
||||
s.team_id,
|
||||
tm.team_name,
|
||||
s.jira_sprint_id,
|
||||
s.sprint_name,
|
||||
s.sprint_activate_datetime,
|
||||
s.sprint_complete_datetime,
|
||||
s.sprint_completion_percentage'
|
||||
. ($orderBy !== '' ? "\n " . $orderBy : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapSprintRow(array $row): array {
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'team_id' => (int) $row['team_id'],
|
||||
'team_name' => trim((string) $row['team_name']),
|
||||
'jira_sprint_id' => (int) $row['jira_sprint_id'],
|
||||
'sprint_name' => $row['sprint_name'],
|
||||
'sprint_activate_datetime' => $row['sprint_activate_datetime'],
|
||||
'sprint_complete_datetime' => $row['sprint_complete_datetime'],
|
||||
'sprint_completion_percentage' => $row['sprint_completion_percentage'] !== null
|
||||
? (float) $row['sprint_completion_percentage']
|
||||
: null,
|
||||
'lead_time_max' => $row['lead_time_max'] !== null ? (int) $row['lead_time_max'] : null,
|
||||
'lead_time_min' => $row['lead_time_min'] !== null ? (int) $row['lead_time_min'] : null,
|
||||
'lead_time_p95' => $row['lead_time_p95'] !== null ? (float) $row['lead_time_p95'] : null,
|
||||
'lead_time_p80' => $row['lead_time_p80'] !== null ? (float) $row['lead_time_p80'] : null,
|
||||
'lead_time_p50' => $row['lead_time_p50'] !== null ? (float) $row['lead_time_p50'] : null,
|
||||
'test_count_max' => $row['test_count_max'] !== null ? (int) $row['test_count_max'] : null,
|
||||
'test_count_min' => $row['test_count_min'] !== null ? (int) $row['test_count_min'] : null,
|
||||
'test_count_p95' => $row['test_count_p95'] !== null ? (float) $row['test_count_p95'] : null,
|
||||
'test_count_p80' => $row['test_count_p80'] !== null ? (float) $row['test_count_p80'] : null,
|
||||
'test_count_p50' => $row['test_count_p50'] !== null ? (float) $row['test_count_p50'] : null,
|
||||
'efficiency_min' => $row['efficiency_min'] !== null ? (float) $row['efficiency_min'] : null,
|
||||
'efficiency_max' => $row['efficiency_max'] !== null ? (float) $row['efficiency_max'] : null,
|
||||
'efficiency_p50' => $row['efficiency_p50'] !== null ? (float) $row['efficiency_p50'] : null,
|
||||
'recast_max' => $row['recast_max'] !== null ? (float) $row['recast_max'] : null,
|
||||
'recast_min' => $row['recast_min'] !== null ? (float) $row['recast_min'] : null,
|
||||
'recast_p95' => $row['recast_p95'] !== null ? (float) $row['recast_p95'] : null,
|
||||
'recast_p80' => $row['recast_p80'] !== null ? (float) $row['recast_p80'] : null,
|
||||
'recast_p50' => $row['recast_p50'] !== null ? (float) $row['recast_p50'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue