Compare commits

..

10 Commits

48 changed files with 4811 additions and 0 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
/local/
/.idea/
/.git/
/frontend/node_modules/
/frontend/dist/
/vendor/
*.md

View File

@ -6,6 +6,8 @@ POSTGRES_PASSWORD=admin
POSTGRES_PORT=5432 POSTGRES_PORT=5432
TZ=Asia/Singapore TZ=Asia/Singapore
# Для Docker POSTGRES_HOST переопределяется в docker-compose.yml на db
JIRA_URL="https://your-domain.atlassian.net" JIRA_URL="https://your-domain.atlassian.net"
JIRA_TOKEN=your-api-token JIRA_TOKEN=your-api-token
JIRA_MAX_RESULTS=1000 JIRA_MAX_RESULTS=1000

2
.gitignore vendored
View File

@ -2,3 +2,5 @@
/.idea/ /.idea/
/local/ /local/
.env .env
/frontend/node_modules/
/frontend/dist/

View File

@ -16,6 +16,41 @@ services:
- "5432:5432" - "5432:5432"
networks: [reports-network] networks: [reports-network]
restart: unless-stopped 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: networks:
reports-network: reports-network:

14
docker/nginx/Dockerfile Normal file
View File

@ -0,0 +1,14 @@
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

26
docker/nginx/default.conf Normal file
View File

@ -0,0 +1,26 @@
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;
}

19
docker/php/Dockerfile Normal file
View File

@ -0,0 +1,19 @@
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

View File

@ -68,6 +68,10 @@ VALUES
(49, 'Екатерина Казакова', 'e.kazakova', 'designer'), (49, 'Екатерина Казакова', 'e.kazakova', 'designer'),
(65, 'Михаил Ягудин', 'm.yagudin', 'frontend'), (65, 'Михаил Ягудин', 'm.yagudin', 'frontend'),
(66, 'Антон Буковщиков', 'a.bukovschikov', 'analytic'), (66, 'Антон Буковщиков', 'a.bukovschikov', 'analytic'),
(71, 'Алексей Сапронов', 'a.sapronov', 'backend'),
(72, 'Василий Шевлоков', 'v.shevlokov', 'frontend'),
(73, 'Кирилл Кетов', 'k.ketov', 'qa'),
(74, 'Илья Шленский', 'i.shlenskiy', 'qa'),
-- bryansk -- bryansk
(50, 'Анастасия Абрамчук', 'a.abramchuk', 'designer'), (50, 'Анастасия Абрамчук', 'a.abramchuk', 'designer'),
@ -149,6 +153,10 @@ VALUES
(1, 49, '2026-01-01 00:00:00Z'), (1, 49, '2026-01-01 00:00:00Z'),
(1, 65, '2026-01-01 00:00:00Z'), (1, 65, '2026-01-01 00:00:00Z'),
(1, 66, '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 -- bryansk
(4, 50, '2026-01-01 00:00:00Z'), (4, 50, '2026-01-01 00:00:00Z'),

102
docs/api/README.md Normal file
View File

@ -0,0 +1,102 @@
# 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#метрики).

49
docs/api/config.md Normal file
View File

@ -0,0 +1,49 @@
# Конфигурация (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).

156
docs/api/sprints.md Normal file
View File

@ -0,0 +1,156 @@
# Спринты (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)

184
docs/api/tasks.md Normal file
View File

@ -0,0 +1,184 @@
# Задачи (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
```

51
docs/api/teams.md Normal file
View File

@ -0,0 +1,51 @@
# Команды (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
```

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!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>

1580
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"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"
}
}

143
frontend/src/App.vue Normal file
View File

@ -0,0 +1,143 @@
<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>

46
frontend/src/api.ts Normal file
View File

@ -0,0 +1,46 @@
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}`)
}

View File

@ -0,0 +1,87 @@
<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>

View File

@ -0,0 +1,306 @@
<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>

View File

@ -0,0 +1,50 @@
<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>

View File

@ -0,0 +1,166 @@
<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>

View File

@ -0,0 +1,58 @@
<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>

View File

@ -0,0 +1,91 @@
<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>

View File

@ -0,0 +1,88 @@
<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>

View File

@ -0,0 +1,124 @@
<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>

View File

@ -0,0 +1,168 @@
<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>

View File

@ -0,0 +1,91 @@
<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>

View File

@ -0,0 +1,26 @@
.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;
}

6
frontend/src/main.ts Normal file
View File

@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router'
import './style.css'
createApp(App).use(router).mount('#app')

29
frontend/src/router.ts Normal file
View File

@ -0,0 +1,29 @@
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,
},
],
})

36
frontend/src/style.css Normal file
View File

@ -0,0 +1,36 @@
*,
*::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;
}

70
frontend/src/types.ts Normal file
View File

@ -0,0 +1,70 @@
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
}

View File

@ -0,0 +1,68 @@
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))
}

View File

@ -0,0 +1,27 @@
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
}

View File

@ -0,0 +1,209 @@
<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>

View File

@ -0,0 +1,130 @@
<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>

View File

@ -0,0 +1,13 @@
<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>

7
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}

View File

@ -0,0 +1,22 @@
{
"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"]
}

21
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"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"]
}

View File

@ -0,0 +1,20 @@
{
"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"]
}

View File

@ -0,0 +1 @@
{"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"}

14
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})

3
public/.htaccess Normal file
View File

@ -0,0 +1,3 @@
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]

69
public/index.php Normal file
View File

@ -0,0 +1,69 @@
<?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);
}

10
public/router.php Normal file
View File

@ -0,0 +1,10 @@
<?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';

View File

@ -0,0 +1,343 @@
<?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,
];
}
}