random improvements (#5382)

* remove permission check, as own timesheets should always be visible
* new methods to create datetime
* allow access to user roles in javascript
* support class for dropdown actions
* allow to edit internal rate
* support human readable duration in export via user configuration
* allow to order timesheet listing by user, exported and billable field
* bump codecov action
This commit is contained in:
Kevin Papst
2025-03-13 18:00:50 +01:00
committed by GitHub
parent 934fdeb107
commit 2a75cd6230
26 changed files with 271 additions and 36 deletions

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