Add Team Summary custom report
Some checks failed
Frontend / Frontend verification (push) Has been cancelled
Lint PHP / Linting (8.2) (push) Has been cancelled
Check .lock files / Verify lock file integrity (push) Has been cancelled
Release Drafter / Verify repository (push) Has been cancelled
Release Drafter / Draft next release (push) Has been cancelled
Tests / Integration (8.2) (push) Has been cancelled
Tests / Integration (8.3) (push) Has been cancelled
Tests / Integration (8.4) (push) Has been cancelled
Tests / Integration (8.5) (push) Has been cancelled
Actions Security Analysis / Scan workflows (push) Has been cancelled

- 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
This commit is contained in:
root
2026-06-18 22:43:12 +00:00
parent 553c87d7ea
commit 74ebac9e94
2 changed files with 212 additions and 0 deletions

View File

@@ -0,0 +1,207 @@
<?php
namespace App\Controller\Reporting;
use App\Entity\User;
use App\Repository\TeamRepository;
use App\Repository\TimesheetRepository;
use App\Reporting\Report;
use App\Reporting\ReportingService;
use DateTime;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/reporting')]
#[IsGranted('view_reporting')]
final class TeamSummaryController
{
public function __construct(
private Connection $connection,
private TeamRepository $teamRepository,
) {}
#[Route(path: '/team_summary', name: 'report_team_summary', methods: ['GET'])]
public function summary(Request $request): Response
{
$teams = $this->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('<option value="%d" %s>%s</option>', $t->getId(), $sel, htmlspecialchars($t->getName()));
}
$html = '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Team Summary</title>
<style>
body{font-family:-apple-system,sans-serif;padding:20px;background:#f5f5f5}
h1,h2{color:#333} form{margin-bottom:20px;display:flex;gap:10px;align-items:center}
select,input{padding:8px;border:1px solid #ccc;border-radius:4px}
button{padding:8px 16px;background:#1976d2;color:#fff;border:none;border-radius:4px;cursor:pointer}
table{border-collapse:collapse;width:100%;margin-bottom:30px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.1)}
th,td{padding:8px 12px;border:1px solid #ddd;text-align:right;font-size:13px}
th{background:#f0f0f0;font-weight:600} td:first-child,th:first-child{text-align:left}
.total-row{font-weight:700;background:#e3f2fd}
.pivot-table{font-size:11px;overflow-x:auto}
.pivot-table td,.pivot-table th{padding:4px 8px;white-space:nowrap}
</style></head><body>
<h1>Team Summary Report</h1>
<form method="get">
<label>Team: <select name="team">' . $teamOptions . '</select></label>
<label>From: <input type="date" name="from" value="' . $from . '"></label>
<label>To: <input type="date" name="to" value="' . $to . '"></label>
<button type="submit">Generate</button>
</form>';
if ($team) {
$html .= '<h2>Time Analysis — ' . htmlspecialchars($team->getName()) . '</h2>';
$html .= '<h3>' . $from . ' to ' . $to . '</h3>';
if ($rows) {
$html .= '<table><thead><tr>
<th>Name</th><th>Billable</th><th>Non-Billable</th><th>Unproductive</th><th>Total Hours</th>
</tr></thead><tbody>';
$totalBillable = $totalNonBill = $totalUnprod = $totalHours = 0;
foreach ($rows as $r) {
$html .= sprintf('<tr><td>%s</td><td>%.2f</td><td>%.2f</td><td>%.2f</td><td>%.2f</td></tr>',
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('<tr class="total-row"><td><strong>TOTAL</strong></td><td><strong>%.2f</strong></td><td><strong>%.2f</strong></td><td><strong>%.2f</strong></td><td><strong>%.2f</strong></td></tr>',
$totalBillable, $totalNonBill, $totalUnprod, $totalHours);
$html .= '</tbody></table>';
} else {
$html .= '<p>No time entries found for this period.</p>';
}
// Section 2: Project Pivot
if ($projectRows && $userColumns) {
$html .= '<h2>Billable Projects</h2>';
$html .= '<div class="pivot-table"><table><thead><tr><th>Company</th><th>Project</th>';
foreach ($userColumns as $uc) {
$html .= '<th>' . htmlspecialchars($uc) . '</th>';
}
$html .= '<th>TOTAL</th></tr></thead><tbody>';
foreach ($projectRows as $pr) {
$html .= '<tr><td>' . htmlspecialchars($pr['company']) . '</td><td>' . htmlspecialchars($pr['project']) . '</td>';
foreach ($userColumns as $uc) {
$val = $pr['users'][$uc] ?? 0;
$html .= '<td>' . ($val > 0 ? sprintf('%.1f', $val) : '') . '</td>';
}
$html .= '<td><strong>' . sprintf('%.1f', $pr['total']) . '</strong></td></tr>';
}
$html .= '</tbody></table></div>';
}
}
$html .= '</body></html>';
return $html;
}
}

View File

@@ -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);
}