Compare commits

...

5 Commits

Author SHA1 Message Date
root
c0f03bc64d Fix Revenue null currency crash on user profile
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
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
Release Drafter / Draft next release (push) Has been cancelled
Lock Threads / Lock inactive issues (push) Has been cancelled
Renovate / renovate (push) Has been cancelled
- TimesheetRepository::getRevenue(): default to ZAR when currency is null
- Set all customer currencies to ZAR (was EUR from old K1 DB)
2026-06-20 17:53:05 +00:00
root
86f3e3c9f3 Include zero-hour users in Time Analysis table
Some checks failed
Lock Threads / Lock inactive issues (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
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
Release Drafter / Draft next release (push) Has been cancelled
Renovate / renovate (push) Has been cancelled
- Remove HAVING total_hours > 0 filter from SQL
- Table always renders even when all users have 0 hours
- Matches old Kimai 1 behavior
2026-06-18 23:14:31 +00:00
root
3cd198c12c Fix billable mapping (0=billable, 1=non-billable, 2=unproductive)
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
- Swapped billable/non-billable SQL values to match migrated data
- All 3 pivot tables always render (empty ones show 'No data')
- Added grand total row to pivot tables
- Initialize unprodRows/userColumnsUP to prevent undefined var error
2026-06-18 23:08:43 +00:00
root
baab938326 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
- 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
2026-06-18 22:59:58 +00:00
root
74ebac9e94 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
2026-06-18 22:43:12 +00:00
4 changed files with 254 additions and 1 deletions

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Controller\Reporting;
use App\Repository\TeamRepository;
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;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/reporting')]
#[IsGranted('view_reporting')]
final class TeamSummaryController extends AbstractController
{
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', 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 = [];
$nonbillRows = [];
$userColumnsNB = [];
$unprodRows = [];
$userColumnsUP = [];
if ($selectedTeamId > 0) {
$team = $this->teamRepository->find($selectedTeamId);
if ($team) {
$rows = $this->getUserTimeAnalysis($selectedTeamId, $from, $to);
[$projectRows, $userColumns] = $this->getProjectPivot($selectedTeamId, $from, $to, '= 0');
[$nonbillRows, $userColumnsNB] = $this->getProjectPivot($selectedTeamId, $from, $to, '= 1');
[$unprodRows, $userColumnsUP] = $this->getProjectPivot($selectedTeamId, $from, $to, '= 2');
}
}
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
{
$sql = "SELECT
u.username,
u.alias,
COALESCE(SUM(CASE WHEN p.billable = 0 THEN ts.duration END), 0) / 3600 AS billable_hours,
COALESCE(SUM(CASE WHEN p.billable = 1 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
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, string $billableComparison): array
{
$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');
$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 " . $billableComparison . "
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();
$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];
}
}

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

View File

@@ -224,7 +224,8 @@ class TimesheetRepository extends EntityRepository
$all = [];
foreach ($qb->getQuery()->getArrayResult() as $item) {
$all[] = new Revenue($item['currency'], $item['revenue']);
$currency = $item['currency'] ?? 'ZAR';
$all[] = new Revenue($currency, $item['revenue']);
}
return $all;

View File

@@ -0,0 +1,109 @@
{% 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>
<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) %}
<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>
{% if rows is empty %}
<tr><td colspan="{{ cols|length + 3 }}" class="text-center text-muted">No data for this period</td></tr>
{% else %}
{% 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 %}
<tr class="table-active fw-bold">
<td colspan="2">TOTAL</td>
{% set gt = 0 %}
{% for uc in cols %}
{% set ct = 0 %}{% for pr in rows %}{% set ct = ct + pr.users[uc]|default(0) %}{% endfor %}
<td class="text-end"><strong>{{ ct > 0 ? ct|number_format(1) }}</strong></td>
{% set gt = gt + ct %}
{% endfor %}
<td class="text-end"><strong>{{ gt|number_format(1) }}</strong></td>
</tr>
{% endif %}
</tbody>
</table></div>
{% endmacro %}
{% import _self as self %}
{{ self.pivot(projectRows, userColumns, 'Billable Projects') }}
{{ self.pivot(nonbillRows, userColumnsNB, 'Non-Billable Projects') }}
{{ self.pivot(unprodRows, userColumnsUP, 'Unproductive Projects') }}
</div>
{% endif %}
</div>
{% endblock %}