Добавил филтры на страницу с LTD
parent
99f6f5ab4e
commit
c5e68efb5d
|
|
@ -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`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 !== '')
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue