Добавил филтры на страницу с LTD

master
Andrew 2026-07-07 11:46:06 +04:00
parent 99f6f5ab4e
commit c5e68efb5d
8 changed files with 681 additions and 47 deletions

View File

@ -1,4 +1,12 @@
import type { AppConfig, PaginatedTeamTasks, Sprint, SprintTask, Team } from './types'
import type {
AppConfig,
PaginatedTeamTasks,
Sprint,
SprintTask,
Team,
TeamTaskFilterOptions,
TeamTaskFilterParams,
} from './types'
async function request<T>(url: string): Promise<T> {
const response = await fetch(url)
@ -36,11 +44,28 @@ export function fetchTeamTasks(
teamId: number,
page = 1,
perPage = 50,
filters: TeamTaskFilterParams = {},
): Promise<PaginatedTeamTasks> {
const params = new URLSearchParams({
page: String(page),
per_page: String(perPage),
})
for (const taskType of filters.taskTypes ?? []) {
params.append('task_type', taskType)
}
for (const assignee of filters.assignees ?? []) {
params.append('assignee', assignee)
}
for (const sprintId of filters.sprintIds ?? []) {
params.append('sprint_id', String(sprintId))
}
return request<PaginatedTeamTasks>(`/api/teams/${teamId}/tasks?${params}`)
}
export function fetchTeamTaskFilters(teamId: number): Promise<TeamTaskFilterOptions> {
return request<TeamTaskFilterOptions>(`/api/teams/${teamId}/tasks/filters`)
}

View File

@ -0,0 +1,215 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { SelectOption } from '../utils/teamLtdFilters'
const props = defineProps<{
label: string
options: SelectOption[]
placeholder?: string
}>()
const model = defineModel<string[]>({ default: () => [] })
const open = ref(false)
const root = ref<HTMLElement | null>(null)
const selectedLabel = computed(() => {
if (model.value.length === 0) {
return props.placeholder ?? 'Все'
}
if (model.value.length === 1) {
const option = props.options.find((item) => item.value === model.value[0])
return option?.label ?? model.value[0]
}
return `Выбрано: ${model.value.length}`
})
function toggleOpen() {
open.value = !open.value
}
function toggleValue(value: string) {
if (model.value.includes(value)) {
model.value = model.value.filter((item) => item !== value)
return
}
model.value = [...model.value, value]
}
function clearSelection() {
model.value = []
}
function handleDocumentClick(event: MouseEvent) {
if (!root.value?.contains(event.target as Node)) {
open.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
})
onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick)
})
</script>
<template>
<div ref="root" class="multi-select">
<button
type="button"
class="multi-select__trigger"
:class="{ 'multi-select__trigger--open': open, 'multi-select__trigger--active': model.length > 0 }"
@click.stop="toggleOpen"
>
<span class="multi-select__label">{{ label }}</span>
<span class="multi-select__value">{{ selectedLabel }}</span>
<span class="multi-select__chevron">▾</span>
</button>
<div v-if="open" class="multi-select__panel" @click.stop>
<button
v-if="model.length > 0"
type="button"
class="multi-select__clear"
@click="clearSelection"
>
Сбросить
</button>
<label v-for="option in options" :key="option.value" class="multi-select__option">
<input
type="checkbox"
class="multi-select__checkbox"
:checked="model.includes(option.value)"
@change="toggleValue(option.value)"
/>
<span class="multi-select__option-label">{{ option.label }}</span>
</label>
<div v-if="options.length === 0" class="multi-select__empty">Нет вариантов</div>
</div>
</div>
</template>
<style scoped>
.multi-select {
position: relative;
min-width: 180px;
}
.multi-select__trigger {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
color: #f1f4f7;
font: inherit;
cursor: pointer;
text-align: left;
transition: border-color 0.12s ease, background-color 0.12s ease;
}
.multi-select__trigger:hover,
.multi-select__trigger--open {
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
}
.multi-select__trigger--active {
border-color: rgba(41, 165, 255, 0.45);
}
.multi-select__label {
flex-shrink: 0;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgba(241, 244, 247, 0.45);
}
.multi-select__value {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.875rem;
font-weight: 600;
}
.multi-select__chevron {
flex-shrink: 0;
font-size: 0.875rem;
color: rgba(241, 244, 247, 0.45);
}
.multi-select__panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 20;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 100%;
max-width: 320px;
max-height: 280px;
overflow: auto;
padding: 6px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
background: #2b2e30;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
}
.multi-select__clear {
align-self: flex-start;
margin: 2px 4px 6px;
padding: 0;
border: none;
background: transparent;
color: #29a5ff;
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}
.multi-select__option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.12s ease;
}
.multi-select__option:hover {
background: rgba(255, 255, 255, 0.06);
}
.multi-select__checkbox {
margin: 0;
accent-color: #29a5ff;
}
.multi-select__option-label {
font-size: 0.875rem;
color: #f1f4f7;
}
.multi-select__empty {
padding: 10px;
color: rgba(241, 244, 247, 0.45);
font-size: 0.875rem;
}
</style>

View File

@ -68,3 +68,15 @@ export interface PaginatedTeamTasks {
total: number
total_pages: number
}
export interface TeamTaskFilterOptions {
task_types: string[]
assignees: Array<{ key: string; label: string }>
sprints: Array<{ id: number; name: string }>
}
export interface TeamTaskFilterParams {
taskTypes?: string[]
assignees?: string[]
sprintIds?: number[]
}

View File

@ -0,0 +1,64 @@
export interface SelectOption {
value: string
label: string
}
export interface TeamTaskFilters {
taskTypes: string[]
assignees: string[]
sprintIds: string[]
}
export function emptyTeamTaskFilters(): TeamTaskFilters {
return {
taskTypes: [],
assignees: [],
sprintIds: [],
}
}
export function hasActiveTeamTaskFilters(filters: TeamTaskFilters): boolean {
return filters.taskTypes.length > 0 || filters.assignees.length > 0 || filters.sprintIds.length > 0
}
export function parseTeamTaskFiltersFromQuery(query: Record<string, unknown>): TeamTaskFilters {
return {
taskTypes: parseStringList(query.task_type),
assignees: parseStringList(query.assignee),
sprintIds: parseStringList(query.sprint_id),
}
}
export function buildTeamTaskQuery(page: number, filters: TeamTaskFilters): Record<string, string | string[]> {
const query: Record<string, string | string[]> = {}
if (page > 1) {
query.page = String(page)
}
if (filters.taskTypes.length > 0) {
query.task_type = filters.taskTypes
}
if (filters.assignees.length > 0) {
query.assignee = filters.assignees
}
if (filters.sprintIds.length > 0) {
query.sprint_id = filters.sprintIds
}
return query
}
function parseStringList(value: unknown): string[] {
if (value === null || value === undefined || value === '') {
return []
}
const items = Array.isArray(value) ? value : [value]
return items
.map((item) => String(item))
.filter((item) => item !== '')
}

View File

@ -1,10 +1,19 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchConfig, fetchTeamTasks } from '../api'
import { fetchConfig, fetchTeamTaskFilters, fetchTeamTasks } from '../api'
import type { TeamTask } from '../types'
import type { TeamTaskFilterOptions } from '../types'
import MultiSelectFilter from '../components/MultiSelectFilter.vue'
import Pagination from '../components/Pagination.vue'
import SprintTasksTable from '../components/SprintTasksTable.vue'
import type { SelectOption, TeamTaskFilters } from '../utils/teamLtdFilters'
import {
buildTeamTaskQuery,
emptyTeamTaskFilters,
hasActiveTeamTaskFilters,
parseTeamTaskFiltersFromQuery,
} from '../utils/teamLtdFilters'
const route = useRoute()
const router = useRouter()
@ -17,23 +26,81 @@ const totalPages = ref(0)
const total = ref(0)
const loading = ref(true)
const error = ref<string | null>(null)
const filterOptions = ref<TeamTaskFilterOptions>({
task_types: [],
assignees: [],
sprints: [],
})
const filterOptionsError = ref<string | null>(null)
const teamId = computed(() => Number(route.params.teamId))
const filters = computed(() => parseTeamTaskFiltersFromQuery(route.query as Record<string, unknown>))
const taskTypeOptions = computed<SelectOption[]>(() =>
(filterOptions.value?.task_types ?? []).map((taskType) => ({
value: taskType,
label: taskType,
})),
)
const assigneeOptions = computed<SelectOption[]>(() =>
(filterOptions.value?.assignees ?? []).map((assignee) => ({
value: assignee.key,
label: assignee.label,
})),
)
const sprintOptions = computed<SelectOption[]>(() =>
(filterOptions.value?.sprints ?? []).map((sprint) => ({
value: String(sprint.id),
label: sprint.name,
})),
)
const hasFilters = computed(() => hasActiveTeamTaskFilters(filters.value))
function currentPageFromRoute(): number {
const value = Number(route.query.page ?? 1)
return Number.isFinite(value) && value > 0 ? value : 1
}
function filtersToApiParams(currentFilters: TeamTaskFilters) {
return {
taskTypes: currentFilters.taskTypes,
assignees: currentFilters.assignees,
sprintIds: currentFilters.sprintIds.map(Number).filter((id) => id > 0),
}
}
async function loadFilterOptions() {
filterOptionsError.value = null
try {
filterOptions.value = await fetchTeamTaskFilters(teamId.value)
} catch (loadError) {
filterOptions.value = {
task_types: [],
assignees: [],
sprints: [],
}
filterOptionsError.value = loadError instanceof Error
? loadError.message
: 'Не удалось загрузить фильтры'
}
}
async function loadData() {
loading.value = true
error.value = null
page.value = currentPageFromRoute()
try {
await loadFilterOptions()
const [config, result] = await Promise.all([
jiraUrl.value ? Promise.resolve({ jira_url: jiraUrl.value }) : fetchConfig(),
fetchTeamTasks(teamId.value, page.value),
fetchTeamTasks(teamId.value, page.value, 50, filtersToApiParams(filters.value)),
])
jiraUrl.value = config.jira_url
@ -45,7 +112,7 @@ async function loadData() {
if (result.page !== currentPageFromRoute()) {
await router.replace({
query: result.page > 1 ? { page: String(result.page) } : {},
query: buildTeamTaskQuery(result.page, filters.value),
})
}
} catch (loadError) {
@ -55,18 +122,57 @@ async function loadData() {
}
}
function updateFilters(nextFilters: TeamTaskFilters) {
void router.push({
query: buildTeamTaskQuery(1, nextFilters),
})
}
function updateTaskTypes(taskTypes: string[]) {
updateFilters({
...filters.value,
taskTypes,
})
}
function updateAssignees(assignees: string[]) {
updateFilters({
...filters.value,
assignees,
})
}
function updateSprintIds(sprintIds: string[]) {
updateFilters({
...filters.value,
sprintIds,
})
}
function clearFilters() {
updateFilters(emptyTeamTaskFilters())
}
function changePage(nextPage: number) {
void router.push({
query: nextPage > 1 ? { page: String(nextPage) } : {},
query: buildTeamTaskQuery(nextPage, filters.value),
})
}
onMounted(loadData)
watch(
() => [route.params.teamId, route.query.page],
() => {
void loadData()
() => [route.params.teamId, route.query.page, route.query.task_type, route.query.assignee, route.query.sprint_id],
async (newValues, oldValues) => {
if (newValues[0] !== oldValues?.[0]) {
filterOptions.value = {
task_types: [],
assignees: [],
sprints: [],
}
}
await loadData()
},
)
</script>
@ -78,9 +184,44 @@ watch(
<p class="team-ltd__subtitle">Все задачи команды</p>
</header>
<div class="team-ltd__filters">
<MultiSelectFilter
label="Тип"
placeholder="Все типы"
:options="taskTypeOptions"
:model-value="filters.taskTypes"
@update:model-value="updateTaskTypes"
/>
<MultiSelectFilter
label="Исполнитель"
placeholder="Все исполнители"
:options="assigneeOptions"
:model-value="filters.assignees"
@update:model-value="updateAssignees"
/>
<MultiSelectFilter
label="Спринт"
placeholder="Все спринты"
:options="sprintOptions"
:model-value="filters.sprintIds"
@update:model-value="updateSprintIds"
/>
<button
v-if="hasFilters"
type="button"
class="team-ltd__clear-filters"
@click="clearFilters"
>
Сбросить фильтры
</button>
</div>
<p v-if="filterOptionsError" class="team-ltd__filters-error">{{ filterOptionsError }}</p>
<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>
<div v-else-if="tasks.length === 0" class="team-ltd__state">
{{ hasFilters ? 'Нет задач по выбранным фильтрам' : 'Задач пока нет' }}
</div>
<template v-else>
<SprintTasksTable
:tasks="tasks"
@ -117,6 +258,36 @@ watch(
font-size: 0.875rem;
}
.team-ltd__filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.team-ltd__filters-error {
margin: 0 0 20px;
color: #ff6961;
font-size: 0.875rem;
}
.team-ltd__clear-filters {
padding: 8px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: #29a5ff;
font: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
}
.team-ltd__clear-filters:hover {
background: rgba(41, 165, 255, 0.08);
}
.team-ltd__state {
padding: 48px 0;
text-align: center;

View File

@ -1 +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"}
{"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/utils/teamltdfilters.ts","./src/app.vue","./src/components/multiselectfilter.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"}

View File

@ -6,6 +6,28 @@ require_once __DIR__ . '/../init.php';
header('Content-Type: application/json; charset=utf-8');
/**
* @return array<int, string>
*/
function parseStringList(mixed $value): array {
if ($value === null || $value === '') {
return [];
}
$items = is_array($value) ? $value : [$value];
return array_values(array_filter(array_map('strval', $items), static fn (string $item): bool => $item !== ''));
}
/**
* @return array<int, int>
*/
function parseIntList(mixed $value): array {
$items = parseStringList($value);
return array_values(array_filter(array_map('intval', $items), static fn (int $item): bool => $item > 0));
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
@ -32,10 +54,28 @@ try {
exit;
}
if ($method === 'GET' && preg_match('#^/api/teams/(\d+)/tasks/filters$#', $path, $matches)) {
$result = $api->getTeamTaskFilterOptions((int) $matches[1]);
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/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);
$result = $api->getTasksByTeamId(
(int) $matches[1],
$page,
$perPage,
parseStringList($_GET['task_type'] ?? null),
parseStringList($_GET['assignee'] ?? null),
parseIntList($_GET['sprint_id'] ?? null)
);
if ($result === null) {
http_response_code(404);
echo json_encode(['error' => 'Team not found'], JSON_UNESCAPED_UNICODE);

View File

@ -70,6 +70,87 @@ final class ReportsApi {
}
/**
* @return array{
* task_types: array<int, string>,
* assignees: array<int, array{key: string, label: string}>,
* sprints: array<int, array{id: int, name: string}>
* }|null
*/
public function getTeamTaskFilterOptions(int $teamId): ?array {
if (!$this->teamExists($teamId)) {
return null;
}
$taskTypes = $this->db->getList(
'SELECT DISTINCT t.task_type
FROM tasks t
JOIN sprints s ON s.id = t.sprint_id
WHERE s.team_id = :team_id
ORDER BY t.task_type',
['team_id' => $teamId]
);
$assigneeRows = $this->db->getRowset(
'SELECT DISTINCT COALESCE(e.employee_name, \'__unassigned__\') AS assignee_key,
CASE
WHEN e.employee_name IS NULL THEN \'Без исполнителя\'
ELSE e.employee_name
END AS assignee_label
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 assignee_label',
['team_id' => $teamId]
);
usort(
$assigneeRows,
static function (array $left, array $right): int {
if ($left['assignee_key'] === '__unassigned__') {
return 1;
}
if ($right['assignee_key'] === '__unassigned__') {
return -1;
}
return strcmp($left['assignee_label'], $right['assignee_label']);
}
);
$sprintRows = $this->db->getRowset(
'SELECT DISTINCT s.id, s.sprint_name, s.sprint_activate_datetime
FROM tasks t
JOIN sprints s ON s.id = t.sprint_id
WHERE s.team_id = :team_id
ORDER BY s.sprint_activate_datetime DESC',
['team_id' => $teamId]
);
return [
'task_types' => array_map('strval', $taskTypes),
'assignees' => array_map(
static fn (array $row): array => [
'key' => $row['assignee_key'],
'label' => $row['assignee_label'],
],
$assigneeRows
),
'sprints' => array_map(
static fn (array $row): array => [
'id' => (int) $row['id'],
'name' => $row['sprint_name'],
],
$sprintRows
),
];
}
/**
* @param array<int, string> $taskTypes
* @param array<int, string> $assignees
* @param array<int, int> $sprintIds
* @return array{
* team_id: int,
* team_name: string,
@ -80,7 +161,14 @@ final class ReportsApi {
* total_pages: int
* }|null
*/
public function getTasksByTeamId(int $teamId, int $page = 1, int $perPage = 50): ?array {
public function getTasksByTeamId(
int $teamId,
int $page = 1,
int $perPage = 50,
array $taskTypes = [],
array $assignees = [],
array $sprintIds = []
): ?array {
$teamRows = $this->db->getRowset(
'SELECT id, TRIM(team_name) AS team_name FROM teams WHERE id = :team_id',
['team_id' => $teamId]
@ -94,12 +182,16 @@ final class ReportsApi {
$perPage = max(1, min(100, $perPage));
$offset = ($page - 1) * $perPage;
$countRows = $this->db->getRowset(
'SELECT COUNT(*) AS total
FROM tasks t
[$filterSql, $filterParams] = $this->buildTeamTasksFilterSql($taskTypes, $assignees, $sprintIds);
$baseFrom = 'FROM tasks t
JOIN sprints s ON s.id = t.sprint_id
WHERE s.team_id = :team_id',
['team_id' => $teamId]
LEFT JOIN employees e ON e.id = t.employee_id
WHERE s.team_id = :team_id' . $filterSql;
$countRows = $this->db->getRowset(
'SELECT COUNT(*) AS total ' . $baseFrom,
array_merge(['team_id' => $teamId], $filterParams)
);
$total = (int) $countRows[0]['total'];
$totalPages = $perPage > 0 ? (int) ceil($total / $perPage) : 0;
@ -126,18 +218,17 @@ final class ReportsApi {
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
s.sprint_name ' . $baseFrom . '
ORDER BY t.resolution_datetime DESC NULLS LAST, t.task_key ASC
LIMIT :limit OFFSET :offset',
[
'team_id' => $teamId,
'limit' => $perPage,
'offset' => $offset,
]
array_merge(
['team_id' => $teamId],
$filterParams,
[
'limit' => $perPage,
'offset' => $offset,
]
)
);
return [
@ -209,26 +300,6 @@ final class ReportsApi {
);
}
/**
* @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'],
@ -251,6 +322,42 @@ final class ReportsApi {
];
}
private function teamExists(int $teamId): bool {
return $this->db->getCell('SELECT 1 FROM teams WHERE id = :team_id', ['team_id' => $teamId]) !== null;
}
/**
* @param array<int, string> $taskTypes
* @param array<int, string> $assignees
* @param array<int, int> $sprintIds
* @return array{0: string, 1: array<string, mixed>}
*/
private function buildTeamTasksFilterSql(array $taskTypes, array $assignees, array $sprintIds): array {
$conditions = [];
$params = [];
if ($taskTypes !== []) {
$conditions[] = 't.task_type IN (:task_types)';
$params['task_types'] = $taskTypes;
}
if ($assignees !== []) {
$conditions[] = "COALESCE(e.employee_name, '__unassigned__') IN (:assignees)";
$params['assignees'] = $assignees;
}
if ($sprintIds !== []) {
$conditions[] = 's.id IN (:sprint_ids)';
$params['sprint_ids'] = $sprintIds;
}
if ($conditions === []) {
return ['', []];
}
return [' AND ' . implode(' AND ', $conditions), $params];
}
private function buildSprintMetricsSql(string $where, string $orderBy = ''): string {
return 'SELECT s.id,
s.team_id,