From 2a75cd6230238b668ab9e1e363f53956b6cacb78 Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Thu, 13 Mar 2025 18:00:50 +0100 Subject: [PATCH] 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 --- .github/workflows/testing.yaml | 2 +- assets/js/plugins/KimaiUser.js | 7 ++ assets/js/widgets/KimaiPaginatedBoxWidget.js | 3 +- .../TimesheetAbstractController.php | 6 +- src/Controller/TimesheetTeamController.php | 1 + src/Export/Base/SpreadsheetRenderer.php | 10 ++- .../DurationDecimalFormatter.php | 31 +++++++++ .../CellFormatter/DurationFormatter.php | 13 +++- src/Form/TimesheetEditForm.php | 4 ++ src/Form/Toolbar/TimesheetToolbarForm.php | 8 ++- src/Form/Type/InternalRateType.php | 38 +++++++++++ src/Repository/Query/BaseQuery.php | 25 +++++++ src/Repository/Query/TimesheetQuery.php | 6 +- src/Repository/TimesheetRepository.php | 5 -- src/Repository/UserRepository.php | 3 + src/Timesheet/DateTimeFactory.php | 27 ++++++++ src/Twig/LocaleFormatExtensions.php | 4 +- src/Utils/Duration.php | 13 ++-- src/Widget/Type/AbstractWidget.php | 2 +- templates/macros/widgets.html.twig | 2 +- templates/project/embed_activities.html.twig | 6 +- templates/timesheet/edit-default.html.twig | 5 +- tests/Export/Base/CsvRendererTest.php | 6 +- .../DurationDecimalFormatterTest.php | 68 +++++++++++++++++++ .../CellFormatter/DurationFormatterTest.php | 10 +-- tests/Twig/LocaleFormatExtensionsTest.php | 2 + 26 files changed, 271 insertions(+), 36 deletions(-) create mode 100644 src/Export/Package/CellFormatter/DurationDecimalFormatter.php create mode 100644 src/Form/Type/InternalRateType.php create mode 100644 tests/Export/Package/CellFormatter/DurationDecimalFormatterTest.php diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index cf406157..325293e6 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -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 diff --git a/assets/js/plugins/KimaiUser.js b/assets/js/plugins/KimaiUser.js index b3b7e13b..f60ea8f4 100644 --- a/assets/js/plugins/KimaiUser.js +++ b/assets/js/plugins/KimaiUser.js @@ -49,4 +49,11 @@ export default class KimaiUser extends KimaiPlugin { return this.user.superAdmin; } + /** + * @returns {array} + */ + getRoles() { + return this.user.roles; + } + } diff --git a/assets/js/widgets/KimaiPaginatedBoxWidget.js b/assets/js/widgets/KimaiPaginatedBoxWidget.js index 6e5665cf..1515c032 100644 --- a/assets/js/widgets/KimaiPaginatedBoxWidget.js +++ b/assets/js/widgets/KimaiPaginatedBoxWidget.js @@ -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); diff --git a/src/Controller/TimesheetAbstractController.php b/src/Controller/TimesheetAbstractController.php index c5ef9e1e..09244565 100644 --- a/src/Controller/TimesheetAbstractController.php +++ b/src/Controller/TimesheetAbstractController.php @@ -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(); diff --git a/src/Controller/TimesheetTeamController.php b/src/Controller/TimesheetTeamController.php index 67a75835..63ad00c0 100644 --- a/src/Controller/TimesheetTeamController.php +++ b/src/Controller/TimesheetTeamController.php @@ -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); diff --git a/src/Export/Base/SpreadsheetRenderer.php b/src/Export/Base/SpreadsheetRenderer.php index ee5c02f9..50bb0b4f 100644 --- a/src/Export/Base/SpreadsheetRenderer.php +++ b/src/Export/Base/SpreadsheetRenderer.php @@ -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); diff --git a/src/Export/Package/CellFormatter/DurationDecimalFormatter.php b/src/Export/Package/CellFormatter/DurationDecimalFormatter.php new file mode 100644 index 00000000..55e9aee1 --- /dev/null +++ b/src/Export/Package/CellFormatter/DurationDecimalFormatter.php @@ -0,0 +1,31 @@ +duration = new Duration(); + } + + public function formatValue(mixed $value): mixed + { + if (is_numeric($value)) { + return $this->duration->formatDecimal((int) $value); + } + + return 0.0; + } +} diff --git a/src/Export/Package/CellFormatter/DurationFormatter.php b/src/Export/Package/CellFormatter/DurationFormatter.php index 30f1f00f..9d380028 100644 --- a/src/Export/Package/CellFormatter/DurationFormatter.php +++ b/src/Export/Package/CellFormatter/DurationFormatter.php @@ -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); } } diff --git a/src/Form/TimesheetEditForm.php b/src/Form/TimesheetEditForm.php index d3812a74..aff4012d 100644 --- a/src/Form/TimesheetEditForm.php +++ b/src/Form/TimesheetEditForm.php @@ -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, ]); } diff --git a/src/Form/Toolbar/TimesheetToolbarForm.php b/src/Form/Toolbar/TimesheetToolbarForm.php index 2f397d77..d2d597a7 100644 --- a/src/Form/Toolbar/TimesheetToolbarForm.php +++ b/src/Form/Toolbar/TimesheetToolbarForm.php @@ -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 diff --git a/src/Form/Type/InternalRateType.php b/src/Form/Type/InternalRateType.php new file mode 100644 index 00000000..99a7faee --- /dev/null +++ b/src/Form/Type/InternalRateType.php @@ -0,0 +1,38 @@ +setDefaults([ + // documentation is for NelmioApiDocBundle + 'documentation' => [ + 'type' => 'number', + 'description' => 'Internal (hourly) rate', + ], + 'required' => false, + 'label' => 'internalRate', + ]); + } + + public function getParent(): string + { + return MoneyType::class; + } +} diff --git a/src/Repository/Query/BaseQuery.php b/src/Repository/Query/BaseQuery.php index f553d3ae..3cad20cd 100644 --- a/src/Repository/Query/BaseQuery.php +++ b/src/Repository/Query/BaseQuery.php @@ -44,6 +44,10 @@ class BaseQuery * @var array */ private array $orderGroups = []; + /** + * @var array + */ + private array $allowedOrderColumns = []; private ?User $currentUser = null; /** * @var array @@ -428,4 +432,25 @@ class BaseQuery { return $this->isApiCall; } + + /** + * @return string[] + */ + public function getAllowedOrderColumns(): array + { + return $this->allowedOrderColumns; + } + + /** + * @param array $allowedOrderColumns + */ + public function setAllowedOrderColumns(array $allowedOrderColumns): void + { + $this->allowedOrderColumns = $allowedOrderColumns; + } + + public function addAllowedOrderColumns(string $allowedOrderColumn): void + { + $this->allowedOrderColumns[] = $allowedOrderColumn; + } } diff --git a/src/Repository/Query/TimesheetQuery.php b/src/Repository/Query/TimesheetQuery.php index 0d935c41..b7885cd7 100644 --- a/src/Repository/Query/TimesheetQuery.php +++ b/src/Repository/Query/TimesheetQuery.php @@ -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 */ @@ -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 diff --git a/src/Repository/TimesheetRepository.php b/src/Repository/TimesheetRepository.php index 43254c67..28e2ed9a 100644 --- a/src/Repository/TimesheetRepository.php +++ b/src/Repository/TimesheetRepository.php @@ -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)) { diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index b41e3d77..c6773408 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -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()); } diff --git a/src/Timesheet/DateTimeFactory.php b/src/Timesheet/DateTimeFactory.php index 9ec10176..339cf0a8 100644 --- a/src/Timesheet/DateTimeFactory.php +++ b/src/Timesheet/DateTimeFactory.php @@ -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 diff --git a/src/Twig/LocaleFormatExtensions.php b/src/Twig/LocaleFormatExtensions.php index 3b30d18d..ce2de883 100644 --- a/src/Twig/LocaleFormatExtensions.php +++ b/src/Twig/LocaleFormatExtensions.php @@ -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], ]; } diff --git a/src/Utils/Duration.php b/src/Utils/Duration.php index c6914379..6f2b3758 100644 --- a/src/Utils/Duration.php +++ b/src/Utils/Duration.php @@ -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 { diff --git a/src/Widget/Type/AbstractWidget.php b/src/Widget/Type/AbstractWidget.php index 6dd92d1e..395bc92c 100644 --- a/src/Widget/Type/AbstractWidget.php +++ b/src/Widget/Type/AbstractWidget.php @@ -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 diff --git a/templates/macros/widgets.html.twig b/templates/macros/widgets.html.twig index 2ecf1a82..c06e8220 100644 --- a/templates/macros/widgets.html.twig +++ b/templates/macros/widgets.html.twig @@ -61,7 +61,7 @@ {# what to do here ? #} {% else %} {% if values.children is defined and values.children|length > 0 %} -