Добавлена выгрузка в csv в разделе LTD
parent
a83822befa
commit
0793c34e21
|
|
@ -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 []
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue