0
0
Fork 0
mirror of https://github.com/kevinpapst/kimai2.git synced 2025-03-16 05:53:29 +00:00

Merge branch 'main' into feature-console-template-parameter-overwrite

This commit is contained in:
Maarten Becker 2025-03-14 11:39:39 +01:00 committed by GitHub
commit f29d6d4833
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 271 additions and 36 deletions

View file

@ -104,7 +104,7 @@ jobs:
- name: Upload code coverage
if: matrix.php == '8.2'
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml

View file

@ -49,4 +49,11 @@ export default class KimaiUser extends KimaiPlugin {
return this.user.superAdmin;
}
/**
* @returns {array}
*/
getRoles() {
return this.user.roles;
}
}

View file

@ -16,7 +16,6 @@ export default class KimaiPaginatedBoxWidget {
constructor(boxId) {
this.selector = boxId;
const widget = document.querySelector(this.selector);
this.href = widget.dataset['href'];
if (widget.dataset['reload'] !== undefined) {
this.events = widget.dataset['reload'].split(' ');
@ -93,7 +92,7 @@ export default class KimaiPaginatedBoxWidget {
if (node.tagName !== undefined && node.tagName === 'SCRIPT') {
const script = document.createElement('script');
script.text = node.innerHTML;
node.parentNode.replaceChild(script, node );
node.parentNode.replaceChild(script, node);
} else {
for (const child of node.childNodes) {
this._makeScriptExecutable(child);

View file

@ -106,11 +106,11 @@ abstract class TimesheetAbstractController extends AbstractController
}
if ($canSeeUsername) {
$table->addColumn('username', ['class' => 'd-none d-md-table-cell', 'orderBy' => false]);
$table->addColumn('username', ['class' => 'd-none d-md-table-cell', 'orderBy' => 'user']);
}
$table->addColumn('billable', ['class' => 'text-center d-none w-min', 'orderBy' => false]);
$table->addColumn('exported', ['class' => 'text-center d-none w-min', 'orderBy' => false]);
$table->addColumn('billable', ['class' => 'text-center d-none w-min']);
$table->addColumn('exported', ['class' => 'text-center d-none w-min']);
$table->addColumn('actions', ['class' => 'actions']);
$page = $this->createPageSetup();

View file

@ -45,6 +45,7 @@ final class TimesheetTeamController extends TimesheetAbstractController
public function indexAction(int $page, Request $request): Response
{
$query = $this->createDefaultQuery();
$query->addAllowedOrderColumns('user');
$query->setPage($page);
return $this->index($query, $request, 'admin_timesheet', 'admin_timesheet_paginated', TimesheetMetaDisplayEvent::TEAM_TIMESHEET);

View file

@ -11,6 +11,7 @@ namespace App\Export\Base;
use App\Entity\ExportableItem;
use App\Entity\MetaTableTypeInterface;
use App\Entity\User;
use App\Event\ActivityMetaDisplayEvent;
use App\Event\CustomerMetaDisplayEvent;
use App\Event\MetaDisplayEventInterface;
@ -22,6 +23,7 @@ use App\Export\Package\CellFormatter\BooleanFormatter;
use App\Export\Package\CellFormatter\CellFormatterInterface;
use App\Export\Package\CellFormatter\DateFormatter;
use App\Export\Package\CellFormatter\DefaultFormatter;
use App\Export\Package\CellFormatter\DurationDecimalFormatter;
use App\Export\Package\CellFormatter\DurationFormatter;
use App\Export\Package\CellFormatter\RateFormatter;
use App\Export\Package\CellFormatter\TextFormatter;
@ -130,6 +132,7 @@ final class SpreadsheetRenderer
'date' => new DateFormatter(),
'time' => new TimeFormatter(),
'duration' => new DurationFormatter(),
'duration_decimal' => new DurationDecimalFormatter(),
default => new DefaultFormatter()
};
}
@ -141,12 +144,17 @@ final class SpreadsheetRenderer
{
$showRates = $this->isRenderRate($query);
$durationFormatter = 'duration';
if (($user = $this->voter->getUser()) instanceof User) {
$durationFormatter = $user->isExportDecimal() ? 'duration_decimal' : 'duration';
}
$columns = [];
$columns[] = (new Column('date', $this->getFormatter('date')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin());
$columns[] = (new Column('begin', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin())->withColumnWidth(ColumnWidth::SMALL);
$columns[] = (new Column('end', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getEnd())->withColumnWidth(ColumnWidth::SMALL);
$columns[] = (new Column('duration', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
$columns[] = (new Column('duration', $this->getFormatter($durationFormatter)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
if ($showRates) {
$columns[] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL);

View file

@ -0,0 +1,31 @@
<?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\Export\Package\CellFormatter;
use App\Utils\Duration;
final class DurationDecimalFormatter implements CellFormatterInterface
{
private Duration $duration;
public function __construct()
{
$this->duration = new Duration();
}
public function formatValue(mixed $value): mixed
{
if (is_numeric($value)) {
return $this->duration->formatDecimal((int) $value);
}
return 0.0;
}
}

View file

@ -9,14 +9,23 @@
namespace App\Export\Package\CellFormatter;
use App\Utils\Duration;
final class DurationFormatter implements CellFormatterInterface
{
private Duration $duration;
public function __construct()
{
$this->duration = new Duration();
}
public function formatValue(mixed $value): mixed
{
if (is_numeric($value)) {
return (float) number_format($value / 3600, 2, '.', '');
return $this->duration->format((int) $value);
}
return 0.0;
return $this->duration->format(0);
}
}

View file

@ -18,6 +18,7 @@ use App\Form\Type\DescriptionType;
use App\Form\Type\DurationType;
use App\Form\Type\FixedRateType;
use App\Form\Type\HourlyRateType;
use App\Form\Type\InternalRateType;
use App\Form\Type\MetaFieldsCollectionType;
use App\Form\Type\TagsType;
use App\Form\Type\TimePickerType;
@ -365,6 +366,9 @@ class TimesheetEditForm extends AbstractType
])
->add('hourlyRate', HourlyRateType::class, [
'currency' => $currency,
])
->add('internalRate', InternalRateType::class, [
'currency' => $currency,
]);
}

View file

@ -44,8 +44,12 @@ final class TimesheetToolbarForm extends AbstractType
$this->addExportStateChoice($builder);
$this->addPageSizeChoice($builder);
$this->addHiddenPagination($builder);
$this->addOrder($builder);
$this->addOrderBy($builder, TimesheetQuery::TIMESHEET_ORDER_ALLOWED);
$query = $options['data'];
if ($query instanceof TimesheetQuery) {
$this->addOrder($builder);
$this->addOrderBy($builder, $query->getAllowedOrderColumns());
}
}
public function configureOptions(OptionsResolver $resolver): void

View file

@ -0,0 +1,38 @@
<?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\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to set the internal rate.
*/
final class InternalRateType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'number',
'description' => 'Internal (hourly) rate',
],
'required' => false,
'label' => 'internalRate',
]);
}
public function getParent(): string
{
return MoneyType::class;
}
}

View file

@ -44,6 +44,10 @@ class BaseQuery
* @var array<string, string>
*/
private array $orderGroups = [];
/**
* @var array<string>
*/
private array $allowedOrderColumns = [];
private ?User $currentUser = null;
/**
* @var array<Team>
@ -428,4 +432,25 @@ class BaseQuery
{
return $this->isApiCall;
}
/**
* @return string[]
*/
public function getAllowedOrderColumns(): array
{
return $this->allowedOrderColumns;
}
/**
* @param array<string> $allowedOrderColumns
*/
public function setAllowedOrderColumns(array $allowedOrderColumns): void
{
$this->allowedOrderColumns = $allowedOrderColumns;
}
public function addAllowedOrderColumns(string $allowedOrderColumn): void
{
$this->allowedOrderColumns[] = $allowedOrderColumn;
}
}

View file

@ -25,7 +25,10 @@ class TimesheetQuery extends ActivityQuery implements BillableInterface, DateRan
public const STATE_EXPORTED = 4;
public const STATE_NOT_EXPORTED = 5;
public const TIMESHEET_ORDER_ALLOWED = ['begin', 'end', 'duration', 'rate', 'hourlyRate', 'customer', 'project', 'activity', 'description'];
/**
* @deprecated since 2.31.0
*/
public const TIMESHEET_ORDER_ALLOWED = ['begin', 'end', 'duration', 'rate', 'hourlyRate', 'customer', 'project', 'activity', 'description', 'billable', 'exported'];
private ?User $timesheetUser = null;
/** @var array<Activity> */
@ -61,6 +64,7 @@ class TimesheetQuery extends ActivityQuery implements BillableInterface, DateRan
'users' => [],
'activities' => [],
]);
$this->setAllowedOrderColumns(self::TIMESHEET_ORDER_ALLOWED); // @phpstan-ignore-line
}
public function addQueryHint(TimesheetQueryHint $hint): void

View file

@ -730,11 +730,6 @@ class TimesheetRepository extends EntityRepository
->setParameter('begin', \DateTimeImmutable::createFromInterface($startFrom), Types::DATETIME_IMMUTABLE);
}
$qb->join('t.project', 'p');
$qb->join('p.customer', 'c');
$this->addPermissionCriteria($qb, $user);
$results = $qb->getQuery()->getScalarResult();
if (empty($results)) {

View file

@ -169,6 +169,9 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
public function refreshUser(UserInterface $user): User
{
// TODO 3.0 add a last_updated field to user and ONLY load this for comparison.
// TODO then only execute the below code if last_updated != session.last_updated
return $this->loadUserByIdentifier($user->getUserIdentifier());
}

View file

@ -69,6 +69,19 @@ final class DateTimeFactory
return DateTime::createFromInterface($date);
}
private function createDate(DateTimeInterface|string|null $date = null): \DateTimeImmutable
{
if ($date === null) {
$date = 'now';
}
if (\is_string($date)) {
return $this->create($date);
}
return \DateTimeImmutable::createFromInterface($date);
}
public function getStartOfWeek(DateTimeInterface|string|null $date = null): DateTime
{
$date = $this->getDate($date);
@ -139,6 +152,20 @@ final class DateTimeFactory
return new \DateTimeImmutable($datetime, $this->getTimezone());
}
public function createStartOfDay(DateTimeInterface|string|null $date = null): \DateTimeImmutable
{
$date = $this->createDate($date);
return $date->modify('00:00:00');
}
public function createEndOfDay(DateTimeInterface|string|null $date = null): \DateTimeImmutable
{
$date = $this->createDate($date);
return $date->modify('23:59:59');
}
/**
* @param string $format
* @param null|string $datetime

View file

@ -191,6 +191,7 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
$admin = false;
$superAdmin = false;
$timezone = date_default_timezone_get();
$roles = [];
if ($user !== null) {
$browserTitle = (bool) $user->getPreferenceValue('update_browser_title');
@ -200,6 +201,7 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
$admin = $user->isAdmin();
$superAdmin = $user->isSuperAdmin();
$timezone = $user->getTimezone();
$roles = $user->getRoles();
}
$language ??= $this->locale ?? User::DEFAULT_LANGUAGE;
@ -213,7 +215,7 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
'twentyFourHours' => $this->localeService->is24Hour($this->locale),
'updateBrowserTitle' => $browserTitle,
'timezone' => $timezone,
'user' => ['id' => $id, 'name' => $name, 'admin' => $admin, 'superAdmin' => $superAdmin],
'user' => ['id' => $id, 'name' => $name, 'admin' => $admin, 'superAdmin' => $superAdmin, 'roles' => $roles],
];
}

View file

@ -19,12 +19,17 @@ final class Duration
public const FORMAT_DECIMAL = 'decimal';
public const FORMAT_DEFAULT = '%h:%m';
public function formatDecimal(?int $seconds): float
{
if ($seconds === null || $seconds === 0) {
return 0.0;
}
return (float) number_format($seconds / 3600, 2, '.', '');
}
/**
* Transforms seconds into a duration string.
*
* @param int|null $seconds
* @param string $format
* @return string|null
*/
public function format(?int $seconds, string $format = self::FORMAT_DEFAULT): ?string
{

View file

@ -40,7 +40,7 @@ abstract class AbstractWidget implements WidgetInterface
public function getWidth(): int
{
return WidgetInterface::WIDTH_SMALL;
return WidgetInterface::WIDTH_HALF;
}
public function getPermissions(): array

View file

@ -61,7 +61,7 @@
{# what to do here ? #}
{% else %}
{% if values.children is defined and values.children|length > 0 %}
<div class="dropdown">
<div class="dropdown{% if values.class is defined %} {{ values.class }}{% endif %}">
<button type="button" class="btn {{ btnClasses }} dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ icon(icon, true) }}{% if large %} {{ values.title is defined ? values.title|trans : icon|trans }}{% endif %}
</button>

View file

@ -1,10 +1,8 @@
{% embed '@theme/embeds/card.html.twig' with {'project': project, 'activities': activities, 'page': page} %}
{% embed '@theme/embeds/card.html.twig' with {'project': project, 'activities': activities, 'page': page, 'id': 'activity_list_box'} %}
{% import "activity/actions.html.twig" as actions %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}{{ 'activities'|trans }}{% endblock %}
{% block box_attributes %}
id="activity_list_box" data-reload="kimai.activityUpdate kimai.activityDelete"
{% endblock %}
{% block box_attributes %} data-reload="kimai.activityUpdate kimai.activityDelete" {% endblock %}
{% block box_tools %}
{%- if project.visible and project.customer.visible and is_granted('create_activity') -%}
{{ widgets.card_tool_button('create', {'class': 'modal-ajax-form open-edit', 'title': 'create', 'url': path('admin_activity_create_with_project', {'project': project.id})}) }}

View file

@ -76,7 +76,7 @@
{% if form.metaFields is defined and form.metaFields is not empty %}
{{ form_row(form.metaFields) }}
{% endif %}
{% if form.fixedRate is defined or form.hourlyRate is defined or form.billable is defined or form.billableMode is defined or form.exported is defined %}
{% if form.fixedRate is defined or form.hourlyRate is defined or form.internalRate is defined or form.billable is defined or form.billableMode is defined or form.exported is defined %}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'timesheet_extended_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% block title %}{{ 'extended_settings'|trans }}{% endblock %}
@ -87,6 +87,9 @@
{% if form.hourlyRate is defined %}
{{ form_row(form.hourlyRate, {'row_attr': {'class': 'mb-3 ' ~ form.vars.name ~ '_row_' ~ form.hourlyRate.vars.name}}) }}
{% endif %}
{% if form.internalRate is defined %}
{{ form_row(form.internalRate, {'row_attr': {'class': 'mb-3 ' ~ form.vars.name ~ '_row_' ~ form.internalRate.vars.name}}) }}
{% endif %}
{% if form.billable is defined %}
{{ form_row(form.billable, {'row_attr': {'class': 'mb-3 ' ~ form.vars.name ~ '_row_' ~ form.billable.vars.name}}) }}
{% elseif form.billableMode is defined %}

View file

@ -102,7 +102,8 @@ class CsvRendererTest extends AbstractRendererTestCase
'2019-06-16',
'12:00',
'12:06',
'0.11',
'0:06',
//'0.11',
'EUR',
'0',
'0',
@ -134,7 +135,8 @@ class CsvRendererTest extends AbstractRendererTestCase
'2019-06-16',
'12:00',
'12:06',
'0.11',
'0:06',
//'0.11',
'EUR',
'0',
'0',

View file

@ -0,0 +1,68 @@
<?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\Tests\Export\Package\CellFormatter;
use App\Export\Package\CellFormatter\DurationDecimalFormatter;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Export\Package\CellFormatter\DurationDecimalFormatter
*/
class DurationDecimalFormatterTest extends TestCase
{
public function testFormatValueReturnsFormattedDurationForNumericValue(): void
{
$formatter = new DurationDecimalFormatter();
$result = $formatter->formatValue(7200);
self::assertEquals(2.00, $result);
}
public function testFormatValueReturnsZeroForNonNumericValue(): void
{
$formatter = new DurationDecimalFormatter();
$result = $formatter->formatValue('not a number');
self::assertEquals(0.0, $result);
}
public function testFormatValueReturnsFormattedDurationForFloatValue(): void
{
$formatter = new DurationDecimalFormatter();
$result = $formatter->formatValue(4500.5);
self::assertEquals(1.25, $result);
}
public function testFormatValueReturnsFormattedDurationForStringValue(): void
{
$formatter = new DurationDecimalFormatter();
$result = $formatter->formatValue('4500.5');
self::assertEquals(1.25, $result);
}
public function testFormatValueReturnsZeroForNullValue(): void
{
$formatter = new DurationDecimalFormatter();
$result = $formatter->formatValue(null);
self::assertEquals(0.0, $result);
}
public function testFormatValueReturnsFormattedDurationForNegativeValue(): void
{
$formatter = new DurationDecimalFormatter();
$result = $formatter->formatValue(-3600);
self::assertEquals(-1.00, $result);
}
public function testFormatValueReturnsFormattedDurationForNegativeStringValue(): void
{
$formatter = new DurationDecimalFormatter();
$result = $formatter->formatValue('-3600');
self::assertEquals(-1.00, $result);
}
}

View file

@ -21,34 +21,34 @@ class DurationFormatterTest extends TestCase
{
$formatter = new DurationFormatter();
$result = $formatter->formatValue(7200);
self::assertEquals(2.00, $result);
self::assertEquals('2:00', $result);
}
public function testFormatValueReturnsZeroForNonNumericValue(): void
{
$formatter = new DurationFormatter();
$result = $formatter->formatValue('not a number');
self::assertEquals(0.0, $result);
self::assertEquals('0:00', $result);
}
public function testFormatValueReturnsFormattedDurationForFloatValue(): void
{
$formatter = new DurationFormatter();
$result = $formatter->formatValue(4500.5);
self::assertEquals(1.25, $result);
self::assertEquals('1:15', $result);
}
public function testFormatValueReturnsZeroForNullValue(): void
{
$formatter = new DurationFormatter();
$result = $formatter->formatValue(null);
self::assertEquals(0.0, $result);
self::assertEquals('0:00', $result);
}
public function testFormatValueReturnsFormattedDurationForNegativeValue(): void
{
$formatter = new DurationFormatter();
$result = $formatter->formatValue(-3600);
self::assertEquals(-1.00, $result);
self::assertEquals('-1:00', $result);
}
}

View file

@ -459,6 +459,7 @@ class LocaleFormatExtensionsTest extends TestCase
'name' => null,
'admin' => false,
'superAdmin' => false,
'roles' => [],
],
];
$user = $this->createMock(User::class);
@ -488,6 +489,7 @@ class LocaleFormatExtensionsTest extends TestCase
'name' => 'anonymous',
'admin' => false,
'superAdmin' => false,
'roles' => [],
],
];