From 74ebac9e9419a9c7537aae80d1c43895b2aa4258 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 18 Jun 2026 22:43:12 +0000 Subject: [PATCH] Add Team Summary custom report - src/Controller/Reporting/TeamSummaryController.php: new report with - Section 1: Time Analysis per user (billable/non-billable/unproductive/total hours) - Section 2: Billable Projects pivot table per user - Team selector (mapped from old K1 divisions) - Date range picker - src/Reporting/ReportingService.php: registered report route --- .../Reporting/TeamSummaryController.php | 207 ++++++++++++++++++ src/Reporting/ReportingService.php | 5 + 2 files changed, 212 insertions(+) create mode 100644 src/Controller/Reporting/TeamSummaryController.php diff --git a/src/Controller/Reporting/TeamSummaryController.php b/src/Controller/Reporting/TeamSummaryController.php new file mode 100644 index 00000000..8049c253 --- /dev/null +++ b/src/Controller/Reporting/TeamSummaryController.php @@ -0,0 +1,207 @@ +teamRepository->findAll(); + + $selectedTeamId = $request->query->getInt('team', $request->query->getInt('team_id', 0)); + $from = $request->query->get('from', (new DateTime('first day of this month'))->format('Y-m-d')); + $to = $request->query->get('to', (new DateTime('last day of this month'))->format('Y-m-d')); + + $team = null; + $rows = []; + $projectRows = []; + $userColumns = []; + + if ($selectedTeamId > 0) { + $team = $this->teamRepository->find($selectedTeamId); + if ($team) { + // Section 1: Time Analysis per User + $rows = $this->getUserTimeAnalysis($selectedTeamId, $from, $to); + // Section 2: Billable Projects Pivot + [$projectRows, $userColumns] = $this->getProjectPivot($selectedTeamId, $from, $to); + } + } + + return new Response($this->renderTemplate($teams, $team, $rows, $projectRows, $userColumns, $from, $to)); + } + + private function getUserTimeAnalysis(int $teamId, string $from, string $to): array + { + $sql = "SELECT + u.username, + u.alias, + COALESCE(SUM(CASE WHEN p.billable = 1 THEN ts.duration END), 0) / 3600 AS billable_hours, + COALESCE(SUM(CASE WHEN p.billable = 0 THEN ts.duration END), 0) / 3600 AS nonbillable_hours, + COALESCE(SUM(CASE WHEN p.billable IS NULL OR p.billable NOT IN (0,1) THEN ts.duration END), 0) / 3600 AS unproductive_hours, + COALESCE(SUM(ts.duration), 0) / 3600 AS total_hours + FROM kimai2_users u + JOIN kimai2_users_teams ut ON u.id = ut.user_id + LEFT JOIN kimai2_timesheet ts ON u.id = ts.user + AND DATE(ts.start_time) >= :from + AND DATE(ts.start_time) <= :to + LEFT JOIN kimai2_projects p ON ts.project_id = p.id + WHERE ut.team_id = :teamId AND u.enabled = 1 + GROUP BY u.id, u.username, u.alias + HAVING total_hours > 0 + ORDER BY u.alias"; + + return $this->connection->executeQuery($sql, [ + 'teamId' => $teamId, 'from' => $from, 'to' => $to + ])->fetchAllAssociative(); + } + + private function getProjectPivot(int $teamId, string $from, string $to): array + { + // Get users in this team (columns of the pivot) + $userSql = "SELECT u.id, u.alias FROM kimai2_users u + JOIN kimai2_users_teams ut ON u.id = ut.user_id + WHERE ut.team_id = :teamId AND u.enabled = 1 ORDER BY u.alias"; + $users = $this->connection->executeQuery($userSql, ['teamId' => $teamId])->fetchAllAssociative(); + $userColumns = array_column($users, 'alias'); + + // Get project totals per user + $sql = "SELECT + c.name AS company, + p.name AS project, + COALESCE(SUM(ts.duration), 0) / 3600 AS project_total, + u.alias AS user_alias + FROM kimai2_timesheet ts + JOIN kimai2_users u ON ts.user = u.id + JOIN kimai2_projects p ON ts.project_id = p.id + JOIN kimai2_customers c ON p.customer_id = c.id + JOIN kimai2_users_teams ut ON u.id = ut.user_id + WHERE ut.team_id = :teamId + AND DATE(ts.start_time) >= :from + AND DATE(ts.start_time) <= :to + AND p.billable = 1 + GROUP BY c.id, p.id, u.id + ORDER BY c.name, p.name"; + + $raw = $this->connection->executeQuery($sql, [ + 'teamId' => $teamId, 'from' => $from, 'to' => $to + ])->fetchAllAssociative(); + + // Pivot: group by company+project + $projectRows = []; + foreach ($raw as $row) { + $key = $row['company'] . '|||' . $row['project']; + if (!isset($projectRows[$key])) { + $projectRows[$key] = [ + 'company' => $row['company'], + 'project' => $row['project'], + 'users' => [], + 'total' => 0, + ]; + } + $projectRows[$key]['users'][$row['user_alias']] = round($row['project_total'], 2); + $projectRows[$key]['total'] += round($row['project_total'], 2); + } + + return [array_values($projectRows), $userColumns]; + } + + private function renderTemplate($teams, $team, $rows, $projectRows, $userColumns, $from, $to): string + { + $teamOptions = ''; + foreach ($teams as $t) { + $sel = $team && $t->getId() === $team->getId() ? 'selected' : ''; + $teamOptions .= sprintf('', $t->getId(), $sel, htmlspecialchars($t->getName())); + } + + $html = 'Team Summary + +

Team Summary Report

+
+ + + + +
'; + + if ($team) { + $html .= '

Time Analysis — ' . htmlspecialchars($team->getName()) . '

'; + $html .= '

' . $from . ' to ' . $to . '

'; + + if ($rows) { + $html .= ' + + '; + $totalBillable = $totalNonBill = $totalUnprod = $totalHours = 0; + foreach ($rows as $r) { + $html .= sprintf('', + htmlspecialchars($r['alias'] ?: $r['username']), + $r['billable_hours'], $r['nonbillable_hours'], + $r['unproductive_hours'], $r['total_hours'] + ); + $totalBillable += $r['billable_hours']; + $totalNonBill += $r['nonbillable_hours']; + $totalUnprod += $r['unproductive_hours']; + $totalHours += $r['total_hours']; + } + $html .= sprintf('', + $totalBillable, $totalNonBill, $totalUnprod, $totalHours); + $html .= '
NameBillableNon-BillableUnproductiveTotal Hours
%s%.2f%.2f%.2f%.2f
TOTAL%.2f%.2f%.2f%.2f
'; + } else { + $html .= '

No time entries found for this period.

'; + } + + // Section 2: Project Pivot + if ($projectRows && $userColumns) { + $html .= '

Billable Projects

'; + $html .= '
'; + foreach ($userColumns as $uc) { + $html .= ''; + } + $html .= ''; + foreach ($projectRows as $pr) { + $html .= ''; + foreach ($userColumns as $uc) { + $val = $pr['users'][$uc] ?? 0; + $html .= ''; + } + $html .= ''; + } + $html .= '
CompanyProject' . htmlspecialchars($uc) . 'TOTAL
' . htmlspecialchars($pr['company']) . '' . htmlspecialchars($pr['project']) . '' . ($val > 0 ? sprintf('%.1f', $val) : '') . '' . sprintf('%.1f', $pr['total']) . '
'; + } + } + + $html .= ''; + return $html; + } +} diff --git a/src/Reporting/ReportingService.php b/src/Reporting/ReportingService.php index 81cb03f8..64fde164 100644 --- a/src/Reporting/ReportingService.php +++ b/src/Reporting/ReportingService.php @@ -58,6 +58,11 @@ final class ReportingService } } + // Team Summary Report + if ($this->security->isGranted('report:other')) { + $event->addReport(new Report('report_team_summary', 'report_team_summary', 'report_team_summary', 'team')); + } + $this->dispatcher->dispatch($event); }