Fix Team Summary report — all 3 pivot tables + proper Kimai layout
Some checks failed
Actions Security Analysis / Scan workflows (push) Has been cancelled
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
Some checks failed
Actions Security Analysis / Scan workflows (push) Has been cancelled
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
- Billable Projects (billable=1), Non-Billable (billable=0), Unproductive (billable=2) - Uses Twig macro for DRY pivot table rendering - Extends Kimai reporting/layout.html.twig for proper navbar/breadcrumb - Team selector + date range in card header
This commit is contained in:
@@ -2,13 +2,10 @@
|
||||
|
||||
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\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -16,7 +13,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route(path: '/reporting')]
|
||||
#[IsGranted('view_reporting')]
|
||||
final class TeamSummaryController
|
||||
final class TeamSummaryController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
@@ -28,7 +25,7 @@ final class TeamSummaryController
|
||||
{
|
||||
$teams = $this->teamRepository->findAll();
|
||||
|
||||
$selectedTeamId = $request->query->getInt('team', $request->query->getInt('team_id', 0));
|
||||
$selectedTeamId = $request->query->getInt('team', 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'));
|
||||
|
||||
@@ -36,18 +33,34 @@ final class TeamSummaryController
|
||||
$rows = [];
|
||||
$projectRows = [];
|
||||
$userColumns = [];
|
||||
$nonbillRows = [];
|
||||
$userColumnsNB = [];
|
||||
|
||||
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);
|
||||
[$projectRows, $userColumns] = $this->getProjectPivot($selectedTeamId, $from, $to, '= 1');
|
||||
[$nonbillRows, $userColumnsNB] = $this->getProjectPivot($selectedTeamId, $from, $to, '= 0');
|
||||
[$unprodRows, $userColumnsUP] = $this->getProjectPivot($selectedTeamId, $from, $to, '= 2');
|
||||
}
|
||||
}
|
||||
|
||||
return new Response($this->renderTemplate($teams, $team, $rows, $projectRows, $userColumns, $from, $to));
|
||||
return $this->render('reporting/team_summary.html.twig', [
|
||||
'report_title' => 'report_team_summary',
|
||||
'teams' => $teams,
|
||||
'team' => $team,
|
||||
'rows' => $rows,
|
||||
'projectRows' => $projectRows,
|
||||
'userColumns' => $userColumns,
|
||||
'nonbillRows' => $nonbillRows,
|
||||
'userColumnsNB' => $userColumnsNB,
|
||||
'unprodRows' => $unprodRows,
|
||||
'userColumnsUP' => $userColumnsUP,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'selectedTeamId' => $selectedTeamId,
|
||||
]);
|
||||
}
|
||||
|
||||
private function getUserTimeAnalysis(int $teamId, string $from, string $to): array
|
||||
@@ -75,16 +88,14 @@ final class TeamSummaryController
|
||||
])->fetchAllAssociative();
|
||||
}
|
||||
|
||||
private function getProjectPivot(int $teamId, string $from, string $to): array
|
||||
private function getProjectPivot(int $teamId, string $from, string $to, string $billableComparison): 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,
|
||||
@@ -98,7 +109,7 @@ final class TeamSummaryController
|
||||
WHERE ut.team_id = :teamId
|
||||
AND DATE(ts.start_time) >= :from
|
||||
AND DATE(ts.start_time) <= :to
|
||||
AND p.billable = 1
|
||||
AND p.billable " . $billableComparison . "
|
||||
GROUP BY c.id, p.id, u.id
|
||||
ORDER BY c.name, p.name";
|
||||
|
||||
@@ -106,7 +117,6 @@ final class TeamSummaryController
|
||||
'teamId' => $teamId, 'from' => $from, 'to' => $to
|
||||
])->fetchAllAssociative();
|
||||
|
||||
// Pivot: group by company+project
|
||||
$projectRows = [];
|
||||
foreach ($raw as $row) {
|
||||
$key = $row['company'] . '|||' . $row['project'];
|
||||
@@ -124,84 +134,4 @@ final class TeamSummaryController
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
101
templates/reporting/team_summary.html.twig
Normal file
101
templates/reporting/team_summary.html.twig
Normal file
@@ -0,0 +1,101 @@
|
||||
{% extends 'reporting/layout.html.twig' %}
|
||||
|
||||
{% block report %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<form method="get" class="row g-2 align-items-center">
|
||||
<div class="col-auto">
|
||||
<select name="team" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">-- Select Team --</option>
|
||||
{% for t in teams %}
|
||||
<option value="{{ t.id }}" {% if selectedTeamId == t.id %}selected{% endif %}>{{ t.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="date" name="from" value="{{ from }}" class="form-control form-control-sm" onchange="this.form.submit()">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="date" name="to" value="{{ to }}" class="form-control form-control-sm" onchange="this.form.submit()">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if team %}
|
||||
<div class="card-body">
|
||||
<h3>Time Analysis — {{ team.name }}</h3>
|
||||
<small class="text-muted">{{ from }} – {{ to }}</small>
|
||||
|
||||
{% if rows is empty %}
|
||||
<div class="alert alert-info mt-3">No time entries found for this period.</div>
|
||||
{% else %}
|
||||
<table class="table table-striped table-hover table-sm mt-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th class="text-end">Billable</th>
|
||||
<th class="text-end">Non-Billable</th>
|
||||
<th class="text-end">Unproductive</th>
|
||||
<th class="text-end">Total Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set tb = 0 %}{% set tn = 0 %}{% set tu = 0 %}{% set tt = 0 %}
|
||||
{% for r in rows %}
|
||||
{% set tb = tb + r.billable_hours %}
|
||||
{% set tn = tn + r.nonbillable_hours %}
|
||||
{% set tu = tu + r.unproductive_hours %}
|
||||
{% set tt = tt + r.total_hours %}
|
||||
<tr>
|
||||
<td>{{ r.alias ?: r.username }}</td>
|
||||
<td class="text-end">{{ r.billable_hours|number_format(2) }}</td>
|
||||
<td class="text-end">{{ r.nonbillable_hours|number_format(2) }}</td>
|
||||
<td class="text-end">{{ r.unproductive_hours|number_format(2) }}</td>
|
||||
<td class="text-end"><strong>{{ r.total_hours|number_format(2) }}</strong></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="table-active fw-bold">
|
||||
<td>TOTAL</td>
|
||||
<td class="text-end">{{ tb|number_format(2) }}</td>
|
||||
<td class="text-end">{{ tn|number_format(2) }}</td>
|
||||
<td class="text-end">{{ tu|number_format(2) }}</td>
|
||||
<td class="text-end">{{ tt|number_format(2) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% macro pivot(rows, cols, title) %}
|
||||
{% if rows is not empty %}
|
||||
<h4 class="mt-4">{{ title }}</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover table-sm" style="font-size:11px">
|
||||
<thead><tr>
|
||||
<th>Company</th><th>Project</th>
|
||||
{% for uc in cols %}<th class="text-end">{{ uc }}</th>{% endfor %}
|
||||
<th class="text-end">TOTAL</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for pr in rows %}
|
||||
<tr>
|
||||
<td>{{ pr.company }}</td>
|
||||
<td>{{ pr.project }}</td>
|
||||
{% for uc in cols %}
|
||||
<td class="text-end">{{ pr.users[uc]|default(0) > 0 ? pr.users[uc]|number_format(1) }}</td>
|
||||
{% endfor %}
|
||||
<td class="text-end"><strong>{{ pr.total|number_format(1) }}</strong></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% import _self as self %}
|
||||
{{ self.pivot(projectRows, userColumns, 'Billable Projects') }}
|
||||
{{ self.pivot(nonbillRows, userColumnsNB, 'Non-Billable Projects') }}
|
||||
{{ self.pivot(unprodRows, userColumnsUP, 'Unproductive Projects') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user