Release 2.31 (#5372)

* simplify translation
* bump version
* deprecate translations
* pass date-range as argument to export and timesheet filter URL from monthly overview report
* added class for use in responsive screens
* show technical role name
* simplify multi-update title
* more statistic models
* bump composer packages
This commit is contained in:
Kevin Papst
2025-02-27 17:41:25 +01:00
committed by GitHub
parent 674bf3a6b5
commit 14cdcd3f63
20 changed files with 364 additions and 251 deletions

View File

@@ -16,3 +16,9 @@ Perform EACH version specific task between your version and the new one, otherwi
Removed translations: Removed translations:
- `action.edit`: use `edit` instead - `action.edit`: use `edit` instead
- `my.profile`: use `user_profile` instead - `my.profile`: use `user_profile` instead
- `stats.userAmountToday`: use `` instead
- `stats.userAmountWeek`: use `` instead
- `stats.userAmountMonth`: use `` instead
- `stats.userAmountYear`: use `` instead
- `stats.userAmountTotal`: use `` instead
- `update_multiple`

View File

@@ -52,6 +52,13 @@ table.dataTable thead > tr > th.hw-min {
width: 1%; width: 1%;
white-space: normal; white-space: normal;
} }
@include media-breakpoint-up(sm) {
th.w-sm-min,
td.w-sm-min {
width: 1%;
white-space: nowrap;
}
}
/* If a table column contains ONLY avatar <img> it will collapse, so make it a defined width */ /* If a table column contains ONLY avatar <img> it will collapse, so make it a defined width */
.w-avatar { .w-avatar {
width: 40px; width: 40px;

443
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,11 +17,11 @@ final class Constants
/** /**
* The current release version * The current release version
*/ */
public const VERSION = '2.30.0'; public const VERSION = '2.31.0';
/** /**
* The current release: major * 10000 + minor * 100 + patch * The current release: major * 10000 + minor * 100 + patch
*/ */
public const VERSION_ID = 23000; public const VERSION_ID = 23100;
/** /**
* The software name * The software name
*/ */

View File

@@ -38,7 +38,11 @@ final class DashboardController extends AbstractController
*/ */
private ?array $widgets = null; private ?array $widgets = null;
public function __construct(private EventDispatcherInterface $eventDispatcher, private WidgetService $service, private BookmarkRepository $repository) public function __construct(
private readonly EventDispatcherInterface $eventDispatcher,
private readonly WidgetService $service,
private readonly BookmarkRepository $repository
)
{ {
} }

View File

@@ -59,11 +59,12 @@ final class ProjectDateRangeController extends AbstractController
$byCustomer[$customer->getId()]['projects'][] = $entry; $byCustomer[$customer->getId()]['projects'][] = $entry;
} }
return $this->render('reporting/project_daterange.html.twig', [ return $this->render('reporting/project/daterange.html.twig', [
'report_title' => 'report_project_daterange', 'report_title' => 'report_project_daterange',
'entries' => $byCustomer, 'entries' => $byCustomer,
'form' => $form->createView(), 'form' => $form->createView(),
'queryEnd' => $dateRange->getEnd(), 'queryBegin' => $begin,
'queryEnd' => $end,
]); ]);
} }
} }

View File

@@ -50,16 +50,21 @@ final class ProjectSubscriber extends AbstractActionsSubscriber
$event->addDivider(); $event->addDivider();
} }
$dateRange = '';
if (isset($payload['daterange']) && \is_string($payload['daterange'])) {
$dateRange = $payload['daterange'];
}
if ($this->isGranted('view_activity')) { if ($this->isGranted('view_activity')) {
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activities', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]); $event->addActionToSubmenu('filter', 'activity', ['title' => 'activities', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
} }
if ($this->isGranted('view_other_timesheet')) { if ($this->isGranted('view_other_timesheet')) {
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]); $event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId(), 'daterange' => $dateRange])]);
} }
if ($this->isGranted('create_export')) { if ($this->isGranted('create_export')) {
$event->addActionToSubmenu('filter', 'export', ['title' => 'export', 'url' => $this->path('export', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId(), 'exported' => 1, 'daterange' => ''])]); $event->addActionToSubmenu('filter', 'export', ['title' => 'export', 'url' => $this->path('export', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId(), 'exported' => 1, 'daterange' => $dateRange])]);
} }
if ($event->hasSubmenu('filter')) { if ($event->hasSubmenu('filter')) {

View File

@@ -0,0 +1,54 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Model\Statistic;
final class DateRange
{
/**
* @var array<string, StatisticDate>
*/
private array $days = [];
public function __construct(\DateTimeInterface $begin, \DateTimeInterface $end)
{
if ($end < $begin) {
throw new \InvalidArgumentException('End must be later than begin');
}
$begin = \DateTimeImmutable::createFromInterface($begin);
$begin = $begin->setTime(0, 0, 0);
$end = \DateTimeImmutable::createFromInterface($end);
$end = $end->setTime(23, 59, 59);
do {
$this->days[$begin->format('Y-m-d')] = new StatisticDate($begin);
$begin = $begin->modify('+1 day');
} while ($begin <= $end);
}
/**
* @return array<StatisticDate>
*/
public function getDays(): array
{
return array_values($this->days);
}
public function setDate(StatisticDate $date): void
{
$key = $date->getDate()->format('Y-m-d');
if (!\array_key_exists($key, $this->days)) {
throw new \InvalidArgumentException('Unknown date given');
}
$this->days[$key] = $date;
}
}

View File

@@ -14,6 +14,7 @@ final class StatisticDate extends Timesheet
private \DateTimeInterface $date; private \DateTimeInterface $date;
private int $billableDuration = 0; private int $billableDuration = 0;
private float $billableRate = 0.00; private float $billableRate = 0.00;
private int $amount = 0;
public function __construct(\DateTimeInterface $date) public function __construct(\DateTimeInterface $date)
{ {
@@ -44,4 +45,14 @@ final class StatisticDate extends Timesheet
{ {
$this->billableRate = $billableRate; $this->billableRate = $billableRate;
} }
public function getAmount(): int
{
return $this->amount;
}
public function setAmount(int $amount): void
{
$this->amount = $amount;
}
} }

View File

@@ -11,6 +11,7 @@ namespace App\Twig;
use App\Constants; use App\Constants;
use App\Entity\EntityWithMetaFields; use App\Entity\EntityWithMetaFields;
use App\Form\Type\DateRangeType;
use App\Utils\Color; use App\Utils\Color;
use Twig\Extension\AbstractExtension; use Twig\Extension\AbstractExtension;
use Twig\TwigFilter; use Twig\TwigFilter;
@@ -37,6 +38,7 @@ final class Extensions extends AbstractExtension
public function getFunctions(): array public function getFunctions(): array
{ {
return [ return [
new TwigFunction('date_range', [$this, 'buildDateRange']),
new TwigFunction('report_date', [$this, 'buildReportDate']), new TwigFunction('report_date', [$this, 'buildReportDate']),
new TwigFunction('class_name', [$this, 'getClassName']), new TwigFunction('class_name', [$this, 'getClassName']),
new TwigFunction('iso_day_by_name', [$this, 'getIsoDayByName']), new TwigFunction('iso_day_by_name', [$this, 'getIsoDayByName']),
@@ -90,6 +92,11 @@ final class Extensions extends AbstractExtension
return $dateTime->format(self::REPORT_DATE); return $dateTime->format(self::REPORT_DATE);
} }
public function buildDateRange(\DateTimeInterface $begin, \DateTimeInterface $end): string
{
return $begin->format(self::REPORT_DATE) . DateRangeType::DATE_SPACER . $end->format(self::REPORT_DATE);
}
public function getIsoDayByName(string $weekDay): int public function getIsoDayByName(string $weekDay): int
{ {
$key = array_search( $key = array_search(

View File

@@ -250,7 +250,7 @@
{% if (route is not empty and entries is not null) or multi_update_form is not null %} {% if (route is not empty and entries is not null) or multi_update_form is not null %}
<div class="card-footer d-flex align-items-center"> <div class="card-footer d-flex align-items-center">
{% if multi_update_form is not null %} {% if multi_update_form is not null %}
{{ form_start(multi_update_form, {'attr': {'class': 'multi_update_form', 'style': 'display:none', 'data-question': 'update_multiple'|trans}}) }} {{ form_start(multi_update_form, {'attr': {'class': 'multi_update_form', 'style': 'display:none', 'data-question': '%action%'}}) }}
{% for formChild in multi_update_form.children %} {% for formChild in multi_update_form.children %}
{{ form_widget(formChild) }} {{ form_widget(formChild) }}
{% endfor %} {% endfor %}

View File

@@ -31,7 +31,7 @@
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between mb-2">
<h4 class="card-title"> <h4 class="card-title">
{{ role.getName()|trans }} <span ondblclick="navigator.clipboard.writeText('{{ role.getName() }}');" title="{{ role.getName() }}">{{ role.getName()|trans }}</span>
{% if canEditPermissions and (role.name not in system_roles) %} {% if canEditPermissions and (role.name not in system_roles) %}
&nbsp; &nbsp;
<a href="{{ path('admin_user_role_delete', {'role': role.id, 'csrfToken': token}) }}" class="confirmation-link" data-question="confirm.delete">{{ icon('trash') }}</a> <a href="{{ path('admin_user_role_delete', {'role': role.id, 'csrfToken': token}) }}" class="confirmation-link" data-question="confirm.delete">{{ icon('trash') }}</a>

View File

@@ -1,6 +1,6 @@
{% macro project(project, view, isTable) %} {% macro project(project, view, isTable, params) %}
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
{% set event = actions(app.user, 'project', view, {'project': project, 'token': csrf_token('project.duplicate')}) %} {% set event = actions(app.user, 'project', view, params|default({})|merge({'project': project, 'token': csrf_token('project.duplicate')})) %}
{% if view == 'index' or view == 'custom' or isTable is not null %} {% if view == 'index' or view == 'custom' or isTable is not null %}
{{ widgets.table_actions(event.actions) }} {{ widgets.table_actions(event.actions) }}
{% else %} {% else %}

View File

@@ -99,7 +99,7 @@
{{ progress.progressbar_budget(entry, currency) }} {{ progress.progressbar_budget(entry, currency) }}
{% endif %} {% endif %}
{% elseif name == 'actions' %} {% elseif name == 'actions' %}
{{ projectActions.project(project, 'custom') }} {{ projectActions.project(project, 'custom', true, {'daterange': date_range(queryBegin, queryEnd)}) }}
{% endif %} {% endif %}
</td> </td>
{% endfor %} {% endfor %}

View File

@@ -3,7 +3,7 @@
{% block main %} {% block main %}
{% set formEditTemplate = 'default/_form.html.twig' %} {% set formEditTemplate = 'default/_form.html.twig' %}
{% set formOptions = { {% set formOptions = {
'title': 'update_multiple'|trans({'%action%': 'edit'|trans, '%count%': dto.entities|length}), 'title': 'edit'|trans,
'form': form, 'form': form,
'back': path(back), 'back': path(back),
} %} } %}

View File

@@ -43,7 +43,7 @@ abstract class AbstractUserPeriodControllerTestCase extends AbstractControllerBa
public static function getTestData(): array public static function getTestData(): array
{ {
return [ return [
[4, 'duration', 'Working hours total'], [4, 'duration', 'Total'],
[4, 'rate', 'Total revenue'], [4, 'rate', 'Total revenue'],
[4, 'internalRate', 'Internal price'], [4, 'internalRate', 'Internal price'],
]; ];
@@ -98,6 +98,6 @@ abstract class AbstractUserPeriodControllerTestCase extends AbstractControllerBa
$select = $client->getCrawler()->filterXPath("//select[@id='user']"); $select = $client->getCrawler()->filterXPath("//select[@id='user']");
self::assertEquals(0, $select->count()); self::assertEquals(0, $select->count());
$cell = $client->getCrawler()->filterXPath("//th[contains(@class, 'reportDataTypeTitle')]"); $cell = $client->getCrawler()->filterXPath("//th[contains(@class, 'reportDataTypeTitle')]");
self::assertEquals('Working hours total', $cell->text()); self::assertEquals('Total', $cell->text());
} }
} }

View File

@@ -43,7 +43,7 @@ abstract class AbstractUsersPeriodControllerTestCase extends AbstractControllerB
public static function getTestData(): array public static function getTestData(): array
{ {
return [ return [
['duration', 'Working hours total'], ['duration', 'Total'],
['rate', 'Total revenue'], ['rate', 'Total revenue'],
['internalRate', 'Internal price'], ['internalRate', 'Internal price'],
]; ];

View File

@@ -64,7 +64,7 @@ class ExtensionsTest extends TestCase
public function testGetFunctions(): void public function testGetFunctions(): void
{ {
$functions = ['report_date', 'class_name', 'iso_day_by_name', 'random_color']; $functions = ['date_range', 'report_date', 'class_name', 'iso_day_by_name', 'random_color'];
$sut = $this->getSut(); $sut = $this->getSut();
$twigFunctions = $sut->getFunctions(); $twigFunctions = $sut->getFunctions();
self::assertCount(\count($functions), $twigFunctions); self::assertCount(\count($functions), $twigFunctions);
@@ -221,6 +221,25 @@ sdfsdf' . PHP_EOL . "\n" .
self::assertEquals($expected, $sut->replaceNewline($input, $replacer)); self::assertEquals($expected, $sut->replaceNewline($input, $replacer));
} }
public function testReportDate(): void
{
$sut = $this->getSut();
$begin = new \DateTimeImmutable('2025-02-01 17:13:45');
$end = new \DateTimeImmutable('2025-02-25 06:10:00');
self::assertEquals('2025-02-01 - 2025-02-25', $sut->buildDateRange($begin, $end));
}
public function testFormatReportDate(): void
{
$sut = $this->getSut();
$date = new \DateTimeImmutable('2024-07-23 17:13:45');
self::assertEquals('2024-07-23', $sut->formatReportDate($date));
}
public function testGetRandomColor(): void public function testGetRandomColor(): void
{ {
$sut = $this->getSut(); $sut = $this->getSut();

View File

@@ -904,27 +904,27 @@
</trans-unit> </trans-unit>
<trans-unit id="TdBJBAl" resname="stats.durationToday"> <trans-unit id="TdBJBAl" resname="stats.durationToday">
<source>stats.durationToday</source> <source>stats.durationToday</source>
<target>Arbeitszeit heute</target> <target>Heute</target>
</trans-unit> </trans-unit>
<trans-unit id="XhKalZH" resname="stats.durationWeek"> <trans-unit id="XhKalZH" resname="stats.durationWeek">
<source>stats.durationWeek</source> <source>stats.durationWeek</source>
<target>Arbeitszeit diese Woche</target> <target>Diese Woche</target>
</trans-unit> </trans-unit>
<trans-unit id="uaOwf_P" resname="stats.durationMonth"> <trans-unit id="uaOwf_P" resname="stats.durationMonth">
<source>stats.durationMonth</source> <source>stats.durationMonth</source>
<target>Arbeitszeit diesen Monat</target> <target>Diesen Monat</target>
</trans-unit> </trans-unit>
<trans-unit id="WqF84KR" resname="stats.durationYear"> <trans-unit id="WqF84KR" resname="stats.durationYear">
<source>stats.durationYear</source> <source>stats.durationYear</source>
<target>Arbeitszeit dieses Jahr</target> <target>Dieses Jahr</target>
</trans-unit> </trans-unit>
<trans-unit id="xkugSAA" resname="stats.durationFinancialYear"> <trans-unit id="xkugSAA" resname="stats.durationFinancialYear">
<source>stats.durationFinancialYear</source> <source>stats.durationFinancialYear</source>
<target>Arbeitszeit dieses Geschäftsjahr</target> <target>Geschäftsjahr</target>
</trans-unit> </trans-unit>
<trans-unit id="YtvPnl1" resname="stats.durationTotal"> <trans-unit id="YtvPnl1" resname="stats.durationTotal">
<source>stats.durationTotal</source> <source>stats.durationTotal</source>
<target>Arbeitszeit gesamt</target> <target>Gesamt</target>
</trans-unit> </trans-unit>
<trans-unit id="EI5gPkp" resname="stats.userDurationToday"> <trans-unit id="EI5gPkp" resname="stats.userDurationToday">
<source>stats.userDurationToday</source> <source>stats.userDurationToday</source>

View File

@@ -904,27 +904,27 @@
</trans-unit> </trans-unit>
<trans-unit id="TdBJBAl" resname="stats.durationToday"> <trans-unit id="TdBJBAl" resname="stats.durationToday">
<source>stats.durationToday</source> <source>stats.durationToday</source>
<target>Working hours today</target> <target>Today</target>
</trans-unit> </trans-unit>
<trans-unit id="XhKalZH" resname="stats.durationWeek"> <trans-unit id="XhKalZH" resname="stats.durationWeek">
<source>stats.durationWeek</source> <source>stats.durationWeek</source>
<target>Working hours this week</target> <target>This week</target>
</trans-unit> </trans-unit>
<trans-unit id="uaOwf_P" resname="stats.durationMonth"> <trans-unit id="uaOwf_P" resname="stats.durationMonth">
<source>stats.durationMonth</source> <source>stats.durationMonth</source>
<target>Working hours this month</target> <target>This month</target>
</trans-unit> </trans-unit>
<trans-unit id="WqF84KR" resname="stats.durationYear"> <trans-unit id="WqF84KR" resname="stats.durationYear">
<source>stats.durationYear</source> <source>stats.durationYear</source>
<target>Working hours this year</target> <target>This year</target>
</trans-unit> </trans-unit>
<trans-unit id="xkugSAA" resname="stats.durationFinancialYear"> <trans-unit id="xkugSAA" resname="stats.durationFinancialYear">
<source>stats.durationFinancialYear</source> <source>stats.durationFinancialYear</source>
<target>Working hours this financial year</target> <target>Financial year</target>
</trans-unit> </trans-unit>
<trans-unit id="YtvPnl1" resname="stats.durationTotal"> <trans-unit id="YtvPnl1" resname="stats.durationTotal">
<source>stats.durationTotal</source> <source>stats.durationTotal</source>
<target>Working hours total</target> <target>Total</target>
</trans-unit> </trans-unit>
<trans-unit id="EI5gPkp" resname="stats.userDurationToday"> <trans-unit id="EI5gPkp" resname="stats.userDurationToday">
<source>stats.userDurationToday</source> <source>stats.userDurationToday</source>