diff --git a/frontend/src/utils/teamLtdFilters.ts b/frontend/src/utils/teamLtdFilters.ts index ccbbe16..553e75c 100644 --- a/frontend/src/utils/teamLtdFilters.ts +++ b/frontend/src/utils/teamLtdFilters.ts @@ -51,6 +51,26 @@ export function buildTeamTaskQuery(page: number, filters: TeamTaskFilters): Reco 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[] { if (value === null || value === undefined || value === '') { return [] diff --git a/frontend/src/views/TeamLtdView.vue b/frontend/src/views/TeamLtdView.vue index 1fab1a9..43f35e8 100644 --- a/frontend/src/views/TeamLtdView.vue +++ b/frontend/src/views/TeamLtdView.vue @@ -10,6 +10,7 @@ import SprintTasksTable from '../components/SprintTasksTable.vue' import TeamLtdFooter from '../components/TeamLtdFooter.vue' import type { SelectOption, TeamTaskFilters } from '../utils/teamLtdFilters' import { + buildTeamTaskExportUrl, buildTeamTaskQuery, emptyTeamTaskFilters, hasActiveTeamTaskFilters, @@ -70,6 +71,8 @@ const sprintOptions = computed(() => const hasFilters = computed(() => hasActiveTeamTaskFilters(filters.value)) +const exportUrl = computed(() => buildTeamTaskExportUrl(teamId.value, filters.value)) + function currentPageFromRoute(): number { const value = Number(route.query.page ?? 1) return Number.isFinite(value) && value > 0 ? value : 1 @@ -234,6 +237,13 @@ watch( > Сбросить фильтры + + Выгрузить в CSV +

{{ filterOptionsError }}

@@ -317,6 +327,23 @@ watch( 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 { padding: 48px 0; text-align: center; diff --git a/public/index.php b/public/index.php index bf4262f..0919bc8 100644 --- a/public/index.php +++ b/public/index.php @@ -122,6 +122,70 @@ try { 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)) { $page = max(1, (int) ($_GET['page'] ?? 1)); $perPage = max(1, min(100, (int) ($_GET['per_page'] ?? 50))); diff --git a/src/Features/Reports/ReportsApi.php b/src/Features/Reports/ReportsApi.php index 5c8ca82..179bcd6 100644 --- a/src/Features/Reports/ReportsApi.php +++ b/src/Features/Reports/ReportsApi.php @@ -274,6 +274,76 @@ final class ReportsApi { ]; } + /** + * @param array $taskTypes + * @param array $assignees + * @param array $sprintIds + * @return array{ + * team_id: int, + * team_name: string, + * items: array> + * }|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 $row * @return array{