Добавлена выгрузка в csv в разделе LTD

master
Andrew 2026-07-21 17:17:55 +04:00
parent a83822befa
commit 0793c34e21
4 changed files with 181 additions and 0 deletions

View File

@ -51,6 +51,26 @@ export function buildTeamTaskQuery(page: number, filters: TeamTaskFilters): Reco
return query return query
} }
export function buildTeamTaskExportUrl(teamId: number, filters: TeamTaskFilters): string {
const params = new URLSearchParams()
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[]', sprintId)
}
const query = params.toString()
return `/api/teams/${teamId}/tasks/export${query ? `?${query}` : ''}`
}
function parseStringList(value: unknown): string[] { function parseStringList(value: unknown): string[] {
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') {
return [] return []

View File

@ -10,6 +10,7 @@ import SprintTasksTable from '../components/SprintTasksTable.vue'
import TeamLtdFooter from '../components/TeamLtdFooter.vue' import TeamLtdFooter from '../components/TeamLtdFooter.vue'
import type { SelectOption, TeamTaskFilters } from '../utils/teamLtdFilters' import type { SelectOption, TeamTaskFilters } from '../utils/teamLtdFilters'
import { import {
buildTeamTaskExportUrl,
buildTeamTaskQuery, buildTeamTaskQuery,
emptyTeamTaskFilters, emptyTeamTaskFilters,
hasActiveTeamTaskFilters, hasActiveTeamTaskFilters,
@ -70,6 +71,8 @@ const sprintOptions = computed<SelectOption[]>(() =>
const hasFilters = computed(() => hasActiveTeamTaskFilters(filters.value)) const hasFilters = computed(() => hasActiveTeamTaskFilters(filters.value))
const exportUrl = computed(() => buildTeamTaskExportUrl(teamId.value, filters.value))
function currentPageFromRoute(): number { function currentPageFromRoute(): number {
const value = Number(route.query.page ?? 1) const value = Number(route.query.page ?? 1)
return Number.isFinite(value) && value > 0 ? value : 1 return Number.isFinite(value) && value > 0 ? value : 1
@ -234,6 +237,13 @@ watch(
> >
Сбросить фильтры Сбросить фильтры
</button> </button>
<a
:href="exportUrl"
class="team-ltd__export"
download
>
Выгрузить в CSV
</a>
</div> </div>
<p v-if="filterOptionsError" class="team-ltd__filters-error">{{ filterOptionsError }}</p> <p v-if="filterOptionsError" class="team-ltd__filters-error">{{ filterOptionsError }}</p>
@ -317,6 +327,23 @@ watch(
background: rgba(41, 165, 255, 0.08); background: rgba(41, 165, 255, 0.08);
} }
.team-ltd__export {
margin-left: auto;
padding: 8px 14px;
border: 1px solid rgba(41, 165, 255, 0.45);
border-radius: 8px;
color: #29a5ff;
font-size: 0.875rem;
font-weight: 600;
text-decoration: none;
transition: background-color 0.12s ease, border-color 0.12s ease;
}
.team-ltd__export:hover {
background: rgba(41, 165, 255, 0.12);
border-color: rgba(41, 165, 255, 0.65);
}
.team-ltd__state { .team-ltd__state {
padding: 48px 0; padding: 48px 0;
text-align: center; text-align: center;

View File

@ -122,6 +122,70 @@ try {
exit; exit;
} }
if ($method === 'GET' && preg_match('#^/api/teams/(\d+)/tasks/export$#', $path, $matches)) {
$result = $api->getAllTasksByTeamId(
(int) $matches[1],
parseStringListFromQuery('task_type'),
parseStringListFromQuery('assignee'),
parseIntListFromQuery('sprint_id')
);
if ($result === null) {
http_response_code(404);
echo json_encode(['error' => 'Team not found'], JSON_UNESCAPED_UNICODE);
exit;
}
$fileName = sprintf('ltd_%s_%s.csv', $result['team_id'], date('Y-m-d'));
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $fileName . '"');
$output = fopen('php://output', 'wb');
// BOM для корректного распознавания UTF-8 в Excel.
fwrite($output, "\xEF\xBB\xBF");
fputcsv($output, [
'Задача',
'Название',
'Спринт',
'Тип',
'Исполнитель',
'Роль',
'Оценка (сек)',
'Факт (сек)',
'Lead Time (дни)',
'Переработка (%)',
'Flow Efficiency (%)',
'Ревью',
'Тест',
'Взято в работу',
'Закрыта',
], ';', '"', '');
foreach ($result['items'] as $item) {
fputcsv($output, [
$item['task_key'],
$item['task_title'],
$item['sprint_name'],
$item['task_type'],
$item['employee_name'] ?? '',
$item['employee_role'] ?? '',
$item['estimate_seconds'],
$item['total_time_seconds'],
$item['lead_time_days'] ?? '',
$item['recast_percentage'] ?? '',
$item['efficiency_percentage'] ?? '',
$item['review_count'],
$item['test_count'],
$item['take_in_work_datetime'] ?? '',
$item['resolution_datetime'] ?? '',
], ';', '"', '');
}
fclose($output);
exit;
}
if ($method === 'GET' && preg_match('#^/api/teams/(\d+)/tasks$#', $path, $matches)) { if ($method === 'GET' && preg_match('#^/api/teams/(\d+)/tasks$#', $path, $matches)) {
$page = max(1, (int) ($_GET['page'] ?? 1)); $page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = max(1, min(100, (int) ($_GET['per_page'] ?? 50))); $perPage = max(1, min(100, (int) ($_GET['per_page'] ?? 50)));

View File

@ -274,6 +274,76 @@ final class ReportsApi {
]; ];
} }
/**
* @param array<int, string> $taskTypes
* @param array<int, string> $assignees
* @param array<int, int> $sprintIds
* @return array{
* team_id: int,
* team_name: string,
* items: array<int, array<string, mixed>>
* }|null
*/
public function getAllTasksByTeamId(
int $teamId,
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]
);
if ($teamRows === []) {
return null;
}
[$filterSql, $filterParams] = $this->buildTeamTasksFilterSql($taskTypes, $assignees, $sprintIds);
$baseFrom = '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' . $filterSql;
$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 ' . $baseFrom . '
ORDER BY t.resolution_datetime DESC NULLS LAST, t.task_key ASC',
array_merge(['team_id' => $teamId], $filterParams)
);
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
),
];
}
/** /**
* @param array<string, mixed> $row * @param array<string, mixed> $row
* @return array{ * @return array{