Release 2.6.0 (#4472)

- Added: calendar entry title combination for customer, project and activity
- Added: show not_invoiced and not_exported data in detail screens
- Added: force logout if user is disabled
- Added: reduced amount of database queries on several screens
- Fixed: open-close status on work-contract screen for users without configuration
- Fixed: failsafe order/orderBy in query if manually manipulated to be null
- Fixed: unify statistic calculation (not_invoiced and not_exported) across screens
- Tech: bump packages
- Tech: Symfony 6.4
This commit is contained in:
Kevin Papst
2023-12-17 16:19:50 +01:00
committed by GitHub
parent af82fd040d
commit 5f4b6d3cfa
77 changed files with 1362 additions and 1242 deletions

View File

@@ -8,4 +8,4 @@ contact_links:
about: Want to discuss something with a community? Do it in discussions! about: Want to discuss something with a community? Do it in discussions!
- name: Get Professional support - name: Get Professional support
url: https://www.kimai.org/en/support.html url: https://www.kimai.org/en/support.html
about: As a customer, you will always receive fast and helpful replies from the developer. about: As a customer, you will always receive fast replies from the developer.

View File

@@ -28,11 +28,9 @@ body:
- Docker compose version: [e.g. 1.21.0] - Docker compose version: [e.g. 1.21.0]
Docker compose file: Docker compose file:
```yaml version: '3.5'
version: '3.5' services:
services: image: ...
image: ...
```
validations: validations:
required: true required: true
- type: input - type: input

View File

@@ -60,7 +60,7 @@ $fixer
'lowercase_static_reference' => true, 'lowercase_static_reference' => true,
'magic_constant_casing' => true, 'magic_constant_casing' => true,
'native_function_casing' => true, 'native_function_casing' => true,
'new_with_braces' => true, 'new_with_parentheses' => true,
'no_blank_lines_after_class_opening' => true, 'no_blank_lines_after_class_opening' => true,
'no_blank_lines_after_phpdoc' => true, 'no_blank_lines_after_phpdoc' => true,
'no_empty_comment' => true, 'no_empty_comment' => true,
@@ -82,7 +82,7 @@ $fixer
'no_singleline_whitespace_before_semicolons' => true, 'no_singleline_whitespace_before_semicolons' => true,
'no_spaces_around_offset' => true, 'no_spaces_around_offset' => true,
'no_trailing_comma_in_singleline' => true, 'no_trailing_comma_in_singleline' => true,
'no_unneeded_curly_braces' => true, 'no_unneeded_braces' => true,
'no_unneeded_final_method' => true, 'no_unneeded_final_method' => true,
'no_unused_imports' => true, 'no_unused_imports' => true,
'no_whitespace_before_comma_in_array' => true, 'no_whitespace_before_comma_in_array' => true,
@@ -151,7 +151,7 @@ $fixer
], ],
'scope' => 'namespaced' 'scope' => 'namespaced'
], ],
'native_function_type_declaration_casing' => true, 'native_type_declaration_casing' => true,
'no_alias_functions' => [ 'no_alias_functions' => [
'sets' => [ 'sets' => [
'@internal' '@internal'

View File

@@ -202,7 +202,7 @@
"symfony": { "symfony": {
"id": "01C3FWRDJJEX9K6Y3A4XDFXPBR", "id": "01C3FWRDJJEX9K6Y3A4XDFXPBR",
"allow-contrib": true, "allow-contrib": true,
"require": "6.3.*" "require": "6.4.*"
} }
} }
} }

1669
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,11 @@
framework: framework:
# Deprecations for 7.0
handle_all_throwables: true
# cannot be disabled, because of https://github.com/nelmio/NelmioApiDocBundle/pull/2164
annotations:
enabled: true
# ---------------------------
exceptions: exceptions:
App\Validator\ValidationFailedException: App\Validator\ValidationFailedException:
log_level: debug log_level: debug

View File

@@ -636,16 +636,6 @@ parameters:
count: 1 count: 1
path: src/Command/TranslationCommand.php path: src/Command/TranslationCommand.php
-
message: "#^Cannot access property \\$original on SimpleXMLElement\\|null\\.$#"
count: 1
path: src/Command/TranslationCommand.php
-
message: "#^Cannot access property \\$target\\-language on SimpleXMLElement\\|null\\.$#"
count: 1
path: src/Command/TranslationCommand.php
- -
message: "#^Cannot call method asXML\\(\\) on SimpleXMLElement\\|false\\.$#" message: "#^Cannot call method asXML\\(\\) on SimpleXMLElement\\|false\\.$#"
count: 5 count: 5
@@ -816,11 +806,6 @@ parameters:
count: 1 count: 1
path: src/Configuration/SystemConfiguration.php path: src/Configuration/SystemConfiguration.php
-
message: "#^Method App\\\\Configuration\\\\SystemConfiguration\\:\\:getThemeColorChoices\\(\\) should return string\\|null but returns float\\|int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string\\|true\\.$#"
count: 1
path: src/Configuration/SystemConfiguration.php
- -
message: "#^Method App\\\\Configuration\\\\SystemConfiguration\\:\\:getUserDefaultCurrency\\(\\) should return string but returns bool\\|float\\|int\\|string\\|null\\.$#" message: "#^Method App\\\\Configuration\\\\SystemConfiguration\\:\\:getUserDefaultCurrency\\(\\) should return string but returns bool\\|float\\|int\\|string\\|null\\.$#"
count: 1 count: 1
@@ -1141,11 +1126,6 @@ parameters:
count: 3 count: 3
path: src/Controller/Reporting/ProjectDateRangeController.php path: src/Controller/Reporting/ProjectDateRangeController.php
-
message: "#^Parameter \\#1 \\$begin of method App\\\\Form\\\\Model\\\\DateRange\\:\\:setBegin\\(\\) expects DateTime, DateTime\\|null given\\.$#"
count: 1
path: src/Controller/Reporting/ProjectDateRangeController.php
- -
message: "#^Parameter \\#2 \\$begin of method App\\\\Project\\\\ProjectStatisticService\\:\\:getBudgetStatisticModelForProjectsByDateRange\\(\\) expects DateTime, DateTime\\|null given\\.$#" message: "#^Parameter \\#2 \\$begin of method App\\\\Project\\\\ProjectStatisticService\\:\\:getBudgetStatisticModelForProjectsByDateRange\\(\\) expects DateTime, DateTime\\|null given\\.$#"
count: 1 count: 1
@@ -3471,16 +3451,6 @@ parameters:
count: 1 count: 1
path: src/Form/Type/ColorChoiceType.php path: src/Form/Type/ColorChoiceType.php
-
message: "#^Method App\\\\Form\\\\Type\\\\ColorChoiceType\\:\\:convertStringToColorArray\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Form/Type/ColorChoiceType.php
-
message: "#^Parameter \\#1 \\$config of method App\\\\Form\\\\Type\\\\ColorChoiceType\\:\\:convertStringToColorArray\\(\\) expects string, string\\|null given\\.$#"
count: 1
path: src/Form/Type/ColorChoiceType.php
- -
message: "#^Class App\\\\Form\\\\Type\\\\ColorPickerType implements generic interface Symfony\\\\Component\\\\Form\\\\DataTransformerInterface but does not specify its types\\: T, R$#" message: "#^Class App\\\\Form\\\\Type\\\\ColorPickerType implements generic interface Symfony\\\\Component\\\\Form\\\\DataTransformerInterface but does not specify its types\\: T, R$#"
count: 1 count: 1
@@ -4231,29 +4201,14 @@ parameters:
count: 1 count: 1
path: src/Plugin/PluginMetadata.php path: src/Plugin/PluginMetadata.php
-
message: "#^Cannot access offset 'amount' on mixed\\.$#"
count: 1
path: src/Project/ProjectStatisticService.php
-
message: "#^Cannot access offset 'billable' on mixed\\.$#"
count: 3
path: src/Project/ProjectStatisticService.php
- -
message: "#^Cannot access offset 'duration' on mixed\\.$#" message: "#^Cannot access offset 'duration' on mixed\\.$#"
count: 9
path: src/Project/ProjectStatisticService.php
-
message: "#^Cannot access offset 'exported' on mixed\\.$#"
count: 3 count: 3
path: src/Project/ProjectStatisticService.php path: src/Project/ProjectStatisticService.php
- -
message: "#^Cannot access offset 'id' on mixed\\.$#" message: "#^Cannot access offset 'id' on mixed\\.$#"
count: 8 count: 4
path: src/Project/ProjectStatisticService.php path: src/Project/ProjectStatisticService.php
- -
@@ -4261,11 +4216,6 @@ parameters:
count: 1 count: 1
path: src/Project/ProjectStatisticService.php path: src/Project/ProjectStatisticService.php
-
message: "#^Cannot access offset 'rate' on mixed\\.$#"
count: 6
path: src/Project/ProjectStatisticService.php
- -
message: "#^Cannot call method getEnd\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#" message: "#^Cannot call method getEnd\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
count: 2 count: 2
@@ -4331,11 +4281,6 @@ parameters:
count: 1 count: 1
path: src/Project/ProjectStatisticService.php path: src/Project/ProjectStatisticService.php
-
message: "#^Parameter \\#1 \\$durationTotal of method App\\\\Reporting\\\\ProjectView\\\\ProjectViewModel\\:\\:setDurationTotal\\(\\) expects int, mixed given\\.$#"
count: 1
path: src/Project/ProjectStatisticService.php
- -
message: "#^Parameter \\#1 \\$durationWeek of method App\\\\Reporting\\\\ProjectView\\\\ProjectViewModel\\:\\:setDurationWeek\\(\\) expects int, mixed given\\.$#" message: "#^Parameter \\#1 \\$durationWeek of method App\\\\Reporting\\\\ProjectView\\\\ProjectViewModel\\:\\:setDurationWeek\\(\\) expects int, mixed given\\.$#"
count: 1 count: 1
@@ -4351,11 +4296,6 @@ parameters:
count: 1 count: 1
path: src/Project/ProjectStatisticService.php path: src/Project/ProjectStatisticService.php
-
message: "#^Parameter \\#1 \\$rateTotal of method App\\\\Reporting\\\\ProjectView\\\\ProjectViewModel\\:\\:setRateTotal\\(\\) expects float, mixed given\\.$#"
count: 1
path: src/Project/ProjectStatisticService.php
- -
message: "#^Parameter \\#1 \\$recordDuration of method App\\\\Model\\\\TimesheetCountedStatistic\\:\\:setDurationBillable\\(\\) expects int, float\\|int given\\.$#" message: "#^Parameter \\#1 \\$recordDuration of method App\\\\Model\\\\TimesheetCountedStatistic\\:\\:setDurationBillable\\(\\) expects int, float\\|int given\\.$#"
count: 1 count: 1
@@ -4371,11 +4311,6 @@ parameters:
count: 1 count: 1
path: src/Project/ProjectStatisticService.php path: src/Project/ProjectStatisticService.php
-
message: "#^Parameter \\#1 \\$timesheetCounter of method App\\\\Reporting\\\\ProjectView\\\\ProjectViewModel\\:\\:setTimesheetCounter\\(\\) expects int, mixed given\\.$#"
count: 1
path: src/Project/ProjectStatisticService.php
- -
message: "#^Parameter \\#1 \\$totalDuration of method App\\\\Model\\\\Statistic\\\\Timesheet\\:\\:setTotalDuration\\(\\) expects int, float\\|int given\\.$#" message: "#^Parameter \\#1 \\$totalDuration of method App\\\\Model\\\\Statistic\\\\Timesheet\\:\\:setTotalDuration\\(\\) expects int, float\\|int given\\.$#"
count: 2 count: 2
@@ -4396,11 +4331,6 @@ parameters:
count: 1 count: 1
path: src/Reporting/CustomerMonthlyProjects/CustomerMonthlyProjectsRepository.php path: src/Reporting/CustomerMonthlyProjects/CustomerMonthlyProjectsRepository.php
-
message: "#^Property App\\\\Reporting\\\\ProjectDateRange\\\\ProjectDateRangeQuery\\:\\:\\$month \\(DateTime\\) does not accept DateTime\\|null\\.$#"
count: 1
path: src/Reporting/ProjectDateRange/ProjectDateRangeQuery.php
- -
message: "#^Cannot access offset 'project' on mixed\\.$#" message: "#^Cannot access offset 'project' on mixed\\.$#"
count: 1 count: 1
@@ -4421,11 +4351,6 @@ parameters:
count: 1 count: 1
path: src/Reporting/ProjectDetails/ProjectDetailsModel.php path: src/Reporting/ProjectDetails/ProjectDetailsModel.php
-
message: "#^Method App\\\\Reporting\\\\ProjectView\\\\ProjectViewModel\\:\\:getBudgetStatisticModel\\(\\) should return App\\\\Model\\\\BudgetStatisticModelInterface but returns App\\\\Model\\\\BudgetStatisticModelInterface\\|null\\.$#"
count: 1
path: src/Reporting/ProjectView/ProjectViewModel.php
- -
message: "#^Cannot call method getSearchFields\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#" message: "#^Cannot call method getSearchFields\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1 count: 1
@@ -5136,11 +5061,6 @@ parameters:
count: 2 count: 2
path: src/Saml/SamlProvider.php path: src/Saml/SamlProvider.php
-
message: "#^Parameter \\#1 \\$identifier of method Symfony\\\\Component\\\\Security\\\\Core\\\\User\\\\UserProviderInterface\\:\\:loadUserByIdentifier\\(\\) expects string, string\\|null given\\.$#"
count: 1
path: src/Saml/SamlProvider.php
- -
message: "#^Parameter \\#1 \\$key of function array_key_exists expects int\\|string, mixed given\\.$#" message: "#^Parameter \\#1 \\$key of function array_key_exists expects int\\|string, mixed given\\.$#"
count: 1 count: 1

View File

@@ -38,7 +38,7 @@ final class SessionAuthenticator extends AbstractAuthenticator
return $token; return $token;
} }
public function supports(Request $request): ?bool public function supports(Request $request): bool
{ {
if (str_contains($request->getRequestUri(), '/api/')) { if (str_contains($request->getRequestUri(), '/api/')) {
// API docs can only be access, when the user is logged in // API docs can only be access, when the user is logged in
@@ -62,7 +62,7 @@ final class SessionAuthenticator extends AbstractAuthenticator
return $this->authenticator->onAuthenticationSuccess($request, $token, $firewallName); return $this->authenticator->onAuthenticationSuccess($request, $token, $firewallName);
} }
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{ {
return $this->authenticator->onAuthenticationFailure($request, $exception); return $this->authenticator->onAuthenticationFailure($request, $exception);
} }

View File

@@ -33,7 +33,7 @@ final class TokenAuthenticator extends AbstractAuthenticator
{ {
} }
public function supports(Request $request): ?bool public function supports(Request $request): bool
{ {
if (str_contains($request->getRequestUri(), '/api/')) { if (str_contains($request->getRequestUri(), '/api/')) {
return !str_contains($request->getRequestUri(), '/api/doc'); return !str_contains($request->getRequestUri(), '/api/doc');
@@ -95,7 +95,7 @@ final class TokenAuthenticator extends AbstractAuthenticator
return null; return null;
} }
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{ {
$data = [ $data = [
'message' => $exception instanceof CustomUserMessageAuthenticationException ? $exception->getMessage() : 'Invalid credentials' 'message' => $exception instanceof CustomUserMessageAuthenticationException ? $exception->getMessage() : 'Invalid credentials'

View File

@@ -62,7 +62,7 @@ final class RecentActivitiesSource implements DragAndDropSource
return $this->entries; return $this->entries;
} }
public function getBlockInclude(): ?string public function getBlockInclude(): string
{ {
return 'calendar/drag-drop.html.twig'; return 'calendar/drag-drop.html.twig';
} }

View File

@@ -44,12 +44,14 @@ final class TimesheetEntry implements DragAndDropEntry
public function getTitle(): string public function getTitle(): string
{ {
if ($this->timesheet->getActivity() !== null && $this->timesheet->getActivity()->getName() !== null) { $activity = $this->timesheet->getActivity();
return $this->timesheet->getActivity()->getName(); if ($activity !== null && $activity->getName() !== null) {
return $activity->getName();
} }
if (null !== $this->timesheet->getProject() && $this->timesheet->getProject()->getName() !== null) { $project = $this->timesheet->getProject();
return $this->timesheet->getProject()->getName(); if ($project !== null && $project->getName() !== null) {
return $project->getName();
} }
return $this->timesheet->getDescription() ?? ''; return $this->timesheet->getDescription() ?? '';
@@ -60,7 +62,7 @@ final class TimesheetEntry implements DragAndDropEntry
return $this->color; return $this->color;
} }
public function getBlockName(): ?string public function getBlockName(): string
{ {
return 'dd_timesheet'; return 'dd_timesheet';
} }

View File

@@ -482,10 +482,10 @@ final class SystemConfiguration
return (bool) $this->find('theme.avatar_url'); return (bool) $this->find('theme.avatar_url');
} }
public function getThemeColorChoices(): ?string public function getThemeColorChoices(): string
{ {
$config = $this->find('theme.color_choices'); $config = $this->find('theme.color_choices');
if (!empty($config)) { if (\is_string($config) && $config !== '') {
return $config; return $config;
} }

View File

@@ -17,11 +17,11 @@ class Constants
/** /**
* The current release version * The current release version
*/ */
public const VERSION = '2.5.0'; public const VERSION = '2.6.0';
/** /**
* The current release: major * 10000 + minor * 100 + patch * The current release: major * 10000 + minor * 100 + patch
*/ */
public const VERSION_ID = 20500; public const VERSION_ID = 20600;
/** /**
* The software name * The software name
*/ */

View File

@@ -31,6 +31,8 @@ use App\Form\Type\ActivityType;
use App\Repository\ActivityRateRepository; use App\Repository\ActivityRateRepository;
use App\Repository\ActivityRepository; use App\Repository\ActivityRepository;
use App\Repository\Query\ActivityQuery; use App\Repository\Query\ActivityQuery;
use App\Repository\Query\TeamQuery;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TeamRepository; use App\Repository\TeamRepository;
use App\Utils\DataTable; use App\Utils\DataTable;
use App\Utils\PageSetup; use App\Utils\PageSetup;
@@ -135,6 +137,22 @@ final class ActivityController extends AbstractController
$defaultTeam = null; $defaultTeam = null;
$now = $this->getDateTimeFactory()->createDateTime(); $now = $this->getDateTimeFactory()->createDateTime();
$exportUrl = null;
$invoiceUrl = null;
$params = ['customers[]' => '', 'projects[]' => '', 'activities[]' => $activity->getId(), 'daterange' => '', 'exported' => TimesheetQuery::STATE_NOT_EXPORTED, 'billable' => true];
if ($activity->getProject() !== null) {
$params['projects[]'] = $activity->getProject()->getId();
if ($activity->getProject()->getCustomer() !== null) {
$params['customers[]'] = $activity->getProject()->getCustomer()->getId();
}
}
if ($this->isGranted('create_export')) {
$exportUrl = $this->generateUrl('export', array_merge($params, ['preview' => true]));
}
if ($this->isGranted('view_invoice')) {
$invoiceUrl = $this->generateUrl('invoice', $params);
}
if ($this->isGranted('edit', $activity)) { if ($this->isGranted('edit', $activity)) {
if ($this->isGranted('create_team')) { if ($this->isGranted('create_team')) {
$defaultTeam = $teamRepository->findOneBy(['name' => $activity->getName()]); $defaultTeam = $teamRepository->findOneBy(['name' => $activity->getName()]);
@@ -147,7 +165,9 @@ final class ActivityController extends AbstractController
} }
if ($this->isGranted('permissions', $activity) || $this->isGranted('details', $activity) || $this->isGranted('view_team')) { if ($this->isGranted('permissions', $activity) || $this->isGranted('details', $activity) || $this->isGranted('view_team')) {
$teams = $activity->getTeams(); $query = new TeamQuery();
$query->addActivity($activity);
$teams = $teamRepository->getTeamsForQuery($query);
} }
// additional boxes by plugins // additional boxes by plugins
@@ -168,7 +188,9 @@ final class ActivityController extends AbstractController
'team' => $defaultTeam, 'team' => $defaultTeam,
'teams' => $teams, 'teams' => $teams,
'now' => $now, 'now' => $now,
'boxes' => $boxes 'boxes' => $boxes,
'export_url' => $exportUrl,
'invoice_url' => $invoiceUrl,
]); ]);
} }

View File

@@ -12,10 +12,10 @@ namespace App\Controller\Auth;
use App\Configuration\SamlConfigurationInterface; use App\Configuration\SamlConfigurationInterface;
use App\Saml\SamlAuthFactory; use App\Saml\SamlAuthFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
#[Route(path: '/saml')] #[Route(path: '/saml')]
final class SamlController extends AbstractController final class SamlController extends AbstractController
@@ -32,7 +32,7 @@ final class SamlController extends AbstractController
} }
$session = $request->getSession(); $session = $request->getSession();
$authErrorKey = Security::AUTHENTICATION_ERROR; $authErrorKey = SecurityRequestAttributes::AUTHENTICATION_ERROR;
$error = null; $error = null;

View File

@@ -33,6 +33,8 @@ use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\Query\CustomerQuery; use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery; use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TeamQuery;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TeamRepository; use App\Repository\TeamRepository;
use App\Utils\DataTable; use App\Utils\DataTable;
use App\Utils\PageSetup; use App\Utils\PageSetup;
@@ -307,6 +309,15 @@ final class CustomerController extends AbstractController
$rates = []; $rates = [];
$now = $this->getDateTimeFactory()->createDateTime(); $now = $this->getDateTimeFactory()->createDateTime();
$exportUrl = null;
$invoiceUrl = null;
if ($this->isGranted('create_export')) {
$exportUrl = $this->generateUrl('export', ['customers[]' => $customer->getId(), 'projects[]' => '', 'daterange' => '', 'exported' => TimesheetQuery::STATE_NOT_EXPORTED, 'preview' => true, 'billable' => true]);
}
if ($this->isGranted('view_invoice')) {
$invoiceUrl = $this->generateUrl('invoice', ['customers[]' => $customer->getId(), 'projects[]' => '', 'daterange' => '', 'exported' => TimesheetQuery::STATE_NOT_EXPORTED, 'billable' => true]);
}
if ($this->isGranted('edit', $customer)) { if ($this->isGranted('edit', $customer)) {
if ($this->isGranted('create_team')) { if ($this->isGranted('create_team')) {
$defaultTeam = $teamRepository->findOneBy(['name' => $customer->getName()]); $defaultTeam = $teamRepository->findOneBy(['name' => $customer->getName()]);
@@ -328,7 +339,9 @@ final class CustomerController extends AbstractController
} }
if ($this->isGranted('permissions', $customer) || $this->isGranted('details', $customer) || $this->isGranted('view_team')) { if ($this->isGranted('permissions', $customer) || $this->isGranted('details', $customer) || $this->isGranted('view_team')) {
$teams = $customer->getTeams(); $query = new TeamQuery();
$query->addCustomer($customer);
$teams = $teamRepository->getTeamsForQuery($query);
} }
// additional boxes by plugins // additional boxes by plugins
@@ -353,7 +366,9 @@ final class CustomerController extends AbstractController
'customer_now' => new \DateTime('now', $timezone), 'customer_now' => new \DateTime('now', $timezone),
'rates' => $rates, 'rates' => $rates,
'now' => $now, 'now' => $now,
'boxes' => $boxes 'boxes' => $boxes,
'export_url' => $exportUrl,
'invoice_url' => $invoiceUrl,
]); ]);
} }

View File

@@ -248,7 +248,7 @@ final class ProfileController extends AbstractController
public function preferencesAction(User $profile, Request $request, EventDispatcherInterface $dispatcher, UserRepository $userRepository): Response public function preferencesAction(User $profile, Request $request, EventDispatcherInterface $dispatcher, UserRepository $userRepository): Response
{ {
// we need to prepare the user preferences, which is done via an EventSubscriber // we need to prepare the user preferences, which is done via an EventSubscriber
$event = new PrepareUserEvent($profile); $event = new PrepareUserEvent($profile, false);
$dispatcher->dispatch($event); $dispatcher->dispatch($event);
$form = $this->createPreferencesForm($profile); $form = $this->createPreferencesForm($profile);

View File

@@ -36,6 +36,8 @@ use App\Repository\ProjectRateRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityQuery; use App\Repository\Query\ActivityQuery;
use App\Repository\Query\ProjectQuery; use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TeamQuery;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TeamRepository; use App\Repository\TeamRepository;
use App\Utils\Context; use App\Utils\Context;
use App\Utils\DataTable; use App\Utils\DataTable;
@@ -334,6 +336,15 @@ final class ProjectController extends AbstractController
$rates = []; $rates = [];
$now = $this->getDateTimeFactory()->createDateTime(); $now = $this->getDateTimeFactory()->createDateTime();
$exportUrl = null;
$invoiceUrl = null;
if ($this->isGranted('create_export') && $project->getCustomer() !== null) {
$exportUrl = $this->generateUrl('export', ['customers[]' => $project->getCustomer()->getId(), 'projects[]' => $project->getId(), 'daterange' => '', 'exported' => TimesheetQuery::STATE_NOT_EXPORTED, 'preview' => true, 'billable' => true]);
}
if ($this->isGranted('view_invoice') && $project->getCustomer() !== null) {
$invoiceUrl = $this->generateUrl('invoice', ['customers[]' => $project->getCustomer()->getId(), 'projects[]' => $project->getId(), 'daterange' => '', 'exported' => TimesheetQuery::STATE_NOT_EXPORTED, 'billable' => true]);
}
if ($this->isGranted('edit', $project)) { if ($this->isGranted('edit', $project)) {
if ($this->isGranted('create_team')) { if ($this->isGranted('create_team')) {
$defaultTeam = $teamRepository->findOneBy(['name' => $project->getName()]); $defaultTeam = $teamRepository->findOneBy(['name' => $project->getName()]);
@@ -351,7 +362,9 @@ final class ProjectController extends AbstractController
} }
if ($this->isGranted('permissions', $project) || $this->isGranted('details', $project) || $this->isGranted('view_team')) { if ($this->isGranted('permissions', $project) || $this->isGranted('details', $project) || $this->isGranted('view_team')) {
$teams = $project->getTeams(); $query = new TeamQuery();
$query->addProject($project);
$teams = $teamRepository->getTeamsForQuery($query);
} }
// additional boxes by plugins // additional boxes by plugins
@@ -375,7 +388,9 @@ final class ProjectController extends AbstractController
'teams' => $teams, 'teams' => $teams,
'rates' => $rates, 'rates' => $rates,
'now' => $now, 'now' => $now,
'boxes' => $boxes 'boxes' => $boxes,
'export_url' => $exportUrl,
'invoice_url' => $invoiceUrl,
]); ]);
} }

View File

@@ -30,14 +30,15 @@ final class ProjectDateRangeController extends AbstractController
$dateFactory = $this->getDateTimeFactory(); $dateFactory = $this->getDateTimeFactory();
$user = $this->getUser(); $user = $this->getUser();
$query = new ProjectDaterangeQuery($dateFactory->getStartOfMonth(), $user); $defaultStart = $dateFactory->getStartOfMonth();
$query = new ProjectDaterangeQuery($defaultStart, $user);
$form = $this->createFormForGetRequest(ProjectDateRangeForm::class, $query, [ $form = $this->createFormForGetRequest(ProjectDateRangeForm::class, $query, [
'timezone' => $user->getTimezone() 'timezone' => $user->getTimezone()
]); ]);
$form->submit($request->query->all(), false); $form->submit($request->query->all(), false);
$dateRange = new DateRange(true); $dateRange = new DateRange(true);
$dateRange->setBegin($query->getMonth()); $dateRange->setBegin($query->getMonth() ?? $defaultStart);
$dateRange->setEnd($dateFactory->getEndOfMonth($dateRange->getBegin())); $dateRange->setEnd($dateFactory->getEndOfMonth($dateRange->getBegin()));
$projects = $service->findProjectsForDateRange($query, $dateRange); $projects = $service->findProjectsForDateRange($query, $dateRange);

View File

@@ -64,7 +64,7 @@ final class PasswordResetController extends AbstractController
$username = $request->request->get('username'); $username = $request->request->get('username');
$user = $this->userService->findUserByUsernameOrEmail($username); $user = $this->userService->findUserByUsernameOrEmail($username);
if (null !== $user && !$user->isPasswordRequestNonExpired($this->configuration->getPasswordResetRetryLifetime())) { if (!$user->isPasswordRequestNonExpired($this->configuration->getPasswordResetRetryLifetime())) {
if (!$user->isInternalUser()) { if (!$user->isInternalUser()) {
throw $this->createAccessDeniedException( throw $this->createAccessDeniedException(
sprintf('The user "%s" tried to reset the password, but it is registered as "%s" auth-type.', $user->getUserIdentifier(), $user->getAuth()) sprintf('The user "%s" tried to reset the password, but it is registered as "%s" auth-type.', $user->getUserIdentifier(), $user->getAuth())

View File

@@ -11,6 +11,7 @@ namespace App\Controller;
use App\Configuration\SystemConfiguration; use App\Configuration\SystemConfiguration;
use App\Entity\User; use App\Entity\User;
use App\Event\PrepareUserEvent;
use App\Event\UserPreferenceDisplayEvent; use App\Event\UserPreferenceDisplayEvent;
use App\Export\Spreadsheet\UserExporter; use App\Export\Spreadsheet\UserExporter;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter; use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
@@ -112,7 +113,7 @@ final class UserController extends AbstractController
#[Route(path: '/create', name: 'admin_user_create', methods: ['GET', 'POST'])] #[Route(path: '/create', name: 'admin_user_create', methods: ['GET', 'POST'])]
#[IsGranted('create_user')] #[IsGranted('create_user')]
public function createAction(Request $request, SystemConfiguration $config, UserRepository $userRepository): Response public function createAction(Request $request, SystemConfiguration $config, UserRepository $userRepository, EventDispatcherInterface $dispatcher): Response
{ {
$user = $this->createNewDefaultUser($config); $user = $this->createNewDefaultUser($config);
$editForm = $this->getCreateUserForm($user); $editForm = $this->getCreateUserForm($user);
@@ -126,6 +127,14 @@ final class UserController extends AbstractController
$userRepository->saveUser($user); $userRepository->saveUser($user);
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
try {
$event = new PrepareUserEvent($user, false);
$dispatcher->dispatch($event);
$userRepository->saveUser($user);
} catch (\Exception $ex) {
// it should be no problem, if creating default user preferences fails
}
return $this->redirectToRouteAfterCreate('user_profile_edit', ['username' => $user->getUserIdentifier()]); return $this->redirectToRouteAfterCreate('user_profile_edit', ['username' => $user->getUserIdentifier()]);
} }

View File

@@ -76,9 +76,9 @@ final class WizardController extends AbstractController
$userService->updateUser($user); $userService->updateUser($user);
if ($data['reload'] === '1') { if ($data['reload'] === '1') {
return $this->redirectToRoute('wizard', ['wizard' => 'profile', '_locale' => $data['language']]); return $this->redirectToRoute('wizard', ['wizard' => 'profile', '_locale' => $user->getLanguage()]);
} else { } else {
return $this->redirectToRoute('wizard', ['wizard' => $next, '_locale' => $data['language']]); return $this->redirectToRoute('wizard', ['wizard' => $next, '_locale' => $user->getLanguage()]);
} }
} }

View File

@@ -36,15 +36,12 @@ trait MetaTableTypeTrait
private ?string $name = null; private ?string $name = null;
/** /**
* Value of the meta (custom) field * Value of the meta (custom) field
*
* ATTENTION:
* This field can be used to temporary hold data in another format (e.g. array) during form transformation,
*/ */
#[ORM\Column(name: 'value', type: 'text', length: 65535, nullable: true)] #[ORM\Column(name: 'value', type: 'text', length: 65535, nullable: true)]
#[Assert\Length(max: 65535)] #[Assert\Length(max: 65535)]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Default'])] #[Serializer\Groups(['Default'])]
private mixed $value = null; private ?string $value = null;
#[ORM\Column(name: 'visible', type: 'boolean', nullable: false, options: ['default' => false])] #[ORM\Column(name: 'visible', type: 'boolean', nullable: false, options: ['default' => false])]
#[Assert\NotNull] #[Assert\NotNull]
private bool $visible = false; private bool $visible = false;
@@ -61,6 +58,14 @@ trait MetaTableTypeTrait
*/ */
private array $options = []; private array $options = [];
private int $order = 0; private int $order = 0;
/**
* Used for data conversion during form transformation.
*
* ATTENTION: This field can be used to temporary hold data in another format (e.g. array) during form transformation.
* TODO unclear when "array" should happen. the above statement is old and maybe we can remove the "mixed” type
*/
private mixed $data = null;
private bool $updated = false;
public function getName(): ?string public function getName(): ?string
{ {
@@ -80,41 +85,47 @@ trait MetaTableTypeTrait
public function getValue(): mixed public function getValue(): mixed
{ {
if ($this->value === null) { $value = $this->updated ? $this->data : $this->value;
if ($value === null) {
return null; return null;
} }
return match ($this->type) { return match ($this->type) {
YesNoType::class, CheckboxType::class => (\is_string($this->value) || \is_int($this->value)) ? (bool) $this->value : $this->value, YesNoType::class, CheckboxType::class => (\is_string($value) || \is_int($value)) ? (bool) $value : $value,
IntegerType::class => (\is_string($this->value) || \is_int($this->value)) ? (int) $this->value : $this->value, IntegerType::class => (\is_string($value) || \is_int($value)) ? (int) $value : $value,
NumberType::class => (\is_string($this->value) || \is_float($this->value)) ? (float) $this->value : $this->value, NumberType::class => (\is_string($value) || \is_float($value)) ? (float) $value : $value,
default => $this->value, default => $value
}; };
} }
/** /**
* Value will not be serialized before its stored, so it should be a primitive type. * Value will not be serialized before its stored, so it should be a primitive/scalar type.
*
* @param mixed $value
* @return MetaTableTypeInterface
*/ */
public function setValue(mixed $value): MetaTableTypeInterface public function setValue(mixed $value): MetaTableTypeInterface
{ {
$this->data = $value;
$this->updated = true;
// unchecked checkboxes / false bool would save an empty string in the database // unchecked checkboxes / false bool would save an empty string in the database
// those cannot be searched in the database // those cannot be searched in the database
if (null !== $value) { if (null !== $value) {
switch ($this->type) { switch ($this->type) {
case YesNoType::class: case YesNoType::class:
case CheckboxType::class: case CheckboxType::class:
if (\is_string($value) || \is_bool($value)) { if (!\is_int($value) && !\is_bool($value) && !\is_string($value)) {
$value = (int) $value;
} else {
throw new \InvalidArgumentException('Failed converting meta-field bool value'); throw new \InvalidArgumentException('Failed converting meta-field bool value');
} else {
$value = (string) $value;
} }
} }
} }
$this->value = $value; if ($value === null) {
$this->value = $value;
} elseif (\is_scalar($value)) {
$this->value = (string) $value;
}
return $this; return $this;
} }

View File

@@ -1023,6 +1023,10 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
return false; return false;
} }
if ($this->enabled !== $user->isEnabled()) {
return false;
}
return true; return true;
} }

View File

@@ -13,14 +13,24 @@ use App\Entity\User;
use Symfony\Contracts\EventDispatcher\Event; use Symfony\Contracts\EventDispatcher\Event;
/** /**
* This event should be used, if a user profile is loaded and want to fill the dynamic user preferences * To be used when a user profile is loaded and should be filled with dynamic user preferences.
*
* @internal
*/ */
final class PrepareUserEvent extends Event final class PrepareUserEvent extends Event
{ {
public function __construct(private User $user) public function __construct(private User $user, private bool $booting = true)
{ {
} }
/**
* Whether this event is dispatched for the currently logged in user during kernel boot.
*/
public function isBooting(): bool
{
return $this->booting;
}
public function getUser(): User public function getUser(): User
{ {
return $this->user; return $this->user;

View File

@@ -14,18 +14,26 @@ use App\Entity\UserPreference;
use Symfony\Contracts\EventDispatcher\Event; use Symfony\Contracts\EventDispatcher\Event;
/** /**
* This event should be used, if further user preferences should be added dynamically. * Add further user-preference definitions dynamically.
* This is used on every page load, do not query the database when this is dispatched.
*/ */
final class UserPreferenceEvent extends Event final class UserPreferenceEvent extends Event
{ {
/** /**
* @param User $user * @param array<UserPreference> $preferences
* @param UserPreference[] $preferences
*/ */
public function __construct(private User $user, private array $preferences) public function __construct(private readonly User $user, private array $preferences, private readonly bool $booting = true)
{ {
} }
/**
* Whether this event is dispatched for the currently logged in user during kernel boot.
*/
public function isBooting(): bool
{
return $this->booting;
}
/** /**
* Do not set the preferences directly to the user object, but ONLY via addPreference() * Do not set the preferences directly to the user object, but ONLY via addPreference()
* *

View File

@@ -137,7 +137,7 @@ final class UserPreferenceSubscriber implements EventSubscriberInterface
{ {
$user = $event->getUser(); $user = $event->getUser();
$event = new UserPreferenceEvent($user, $this->getDefaultPreferences($user)); $event = new UserPreferenceEvent($user, $this->getDefaultPreferences($user), $event->isBooting());
$this->eventDispatcher->dispatch($event); $this->eventDispatcher->dispatch($event);
foreach ($event->getPreferences() as $preference) { foreach ($event->getPreferences() as $preference) {

View File

@@ -10,7 +10,9 @@
namespace App\Form; namespace App\Form;
use App\Configuration\SystemConfiguration; use App\Configuration\SystemConfiguration;
use App\Entity\Customer;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Form\Type\CustomerType;
use App\Form\Type\DatePickerType; use App\Form\Type\DatePickerType;
use App\Form\Type\DescriptionType; use App\Form\Type\DescriptionType;
use App\Form\Type\DurationType; use App\Form\Type\DurationType;
@@ -23,6 +25,7 @@ use App\Form\Type\TimesheetBillableType;
use App\Form\Type\UserType; use App\Form\Type\UserType;
use App\Form\Type\YesNoType; use App\Form\Type\YesNoType;
use App\Repository\CustomerRepository; use App\Repository\CustomerRepository;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Timesheet\Calculator\BillableCalculator; use App\Timesheet\Calculator\BillableCalculator;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer; use Symfony\Component\Form\CallbackTransformer;
@@ -49,7 +52,6 @@ class TimesheetEditForm extends AbstractType
$project = null; $project = null;
$customer = null; $customer = null;
$currency = false; $currency = false;
$customerCount = $this->customers->countCustomer(true);
$timezone = $options['timezone']; $timezone = $options['timezone'];
$isNew = true; $isNew = true;
@@ -100,15 +102,33 @@ class TimesheetEditForm extends AbstractType
$this->addDuration($builder, $options, (!$options['allow_begin_datetime'] || !$options['allow_end_datetime']), $isNew); $this->addDuration($builder, $options, (!$options['allow_begin_datetime'] || !$options['allow_end_datetime']), $isNew);
} }
// -----------------------------------------------------
$query = new CustomerFormTypeQuery($customer);
$query->setUser($options['user']); // @phpstan-ignore-line
$qb = $this->customers->getQueryBuilderForFormType($query);
/** @var array<Customer> $customers */
$customers = $qb->getQuery()->getResult();
$customerCount = \count($customers);
if ($this->showCustomer($options, $isNew, $customerCount)) { if ($this->showCustomer($options, $isNew, $customerCount)) {
$this->addCustomer($builder, $customer); $builder->add('customer', CustomerType::class, [
'choices' => $customers,
'data' => $customer,
'required' => false,
'placeholder' => '',
'mapped' => false,
'project_enabled' => true,
]);
} }
$allowCreate = (bool) $this->systemConfiguration->find('activity.allow_inline_create'); // TODO pre-select if only one exists
$this->addProject($builder, $isNew, $project, $customer); $this->addProject($builder, $isNew, $project, $customer);
// TODO make creation possible
//$allowCreate = (bool) $this->systemConfiguration->find('activity.allow_inline_create');
$this->addActivity($builder, $activity, $project, [ $this->addActivity($builder, $activity, $project, [
'allow_create' => $allowCreate && $options['create_activity'], 'allow_create' => false,
// 'allow_create' => $allowCreate && $options['create_activity'],
]); ]);
$descriptionOptions = ['required' => false]; $descriptionOptions = ['required' => false];
@@ -394,7 +414,7 @@ class TimesheetEditForm extends AbstractType
public function configureOptions(OptionsResolver $resolver): void public function configureOptions(OptionsResolver $resolver): void
{ {
$maxMinutes = $this->systemConfiguration->getTimesheetLongRunningDuration(); $maxMinutes = $this->systemConfiguration->getTimesheetLongRunningDuration();
$maxHours = 8; $maxHours = 10;
if ($maxMinutes > 0) { if ($maxMinutes > 0) {
$maxHours = (int) ($maxMinutes / 60); $maxHours = (int) ($maxMinutes / 60);
} }

View File

@@ -29,6 +29,8 @@ final class CalendarTitlePatternType extends AbstractType
public const PATTERN_PROJECT_DESCRIPTION = self::PATTERN_PROJECT . self::SPACER . self::PATTERN_DESCRIPTION; public const PATTERN_PROJECT_DESCRIPTION = self::PATTERN_PROJECT . self::SPACER . self::PATTERN_DESCRIPTION;
public const PATTERN_CUSTOMER_DESCRIPTION = self::PATTERN_CUSTOMER . self::SPACER . self::PATTERN_DESCRIPTION; public const PATTERN_CUSTOMER_DESCRIPTION = self::PATTERN_CUSTOMER . self::SPACER . self::PATTERN_DESCRIPTION;
public const PATTERN_PROJECT_CUSTOMER = self::PATTERN_PROJECT . self::SPACER . self::PATTERN_CUSTOMER; public const PATTERN_PROJECT_CUSTOMER = self::PATTERN_PROJECT . self::SPACER . self::PATTERN_CUSTOMER;
public const PATTERN_CUSTOMER_PROJECT = self::PATTERN_CUSTOMER . self::SPACER . self::PATTERN_PROJECT;
public const PATTERN_PROJECT_ACTIVITY = self::PATTERN_PROJECT . self::SPACER . self::PATTERN_ACTIVITY;
public function __construct(private TranslatorInterface $translator) public function __construct(private TranslatorInterface $translator)
{ {
@@ -54,6 +56,8 @@ final class CalendarTitlePatternType extends AbstractType
$project . self::SPACER . $description => CalendarTitlePatternType::PATTERN_PROJECT_DESCRIPTION, $project . self::SPACER . $description => CalendarTitlePatternType::PATTERN_PROJECT_DESCRIPTION,
$customer . self::SPACER . $description => CalendarTitlePatternType::PATTERN_CUSTOMER_DESCRIPTION, $customer . self::SPACER . $description => CalendarTitlePatternType::PATTERN_CUSTOMER_DESCRIPTION,
$project . self::SPACER . $customer => CalendarTitlePatternType::PATTERN_PROJECT_CUSTOMER, $project . self::SPACER . $customer => CalendarTitlePatternType::PATTERN_PROJECT_CUSTOMER,
$customer . self::SPACER . $project => CalendarTitlePatternType::PATTERN_CUSTOMER_PROJECT,
$project . self::SPACER . $activity => CalendarTitlePatternType::PATTERN_PROJECT_ACTIVITY,
] ]
]); ]);
} }

View File

@@ -69,6 +69,10 @@ final class ColorChoiceType extends AbstractType implements DataTransformerInter
]); ]);
} }
/**
* @param string $config
* @return array<string, string>
*/
private function convertStringToColorArray(string $config): array private function convertStringToColorArray(string $config): array
{ {
$config = explode(',', $config); $config = explode(',', $config);

View File

@@ -16,6 +16,9 @@ use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Core\User\UserProviderInterface;
/**
* @template-implements UserProviderInterface<User>
*/
final class LdapUserProvider implements UserProviderInterface final class LdapUserProvider implements UserProviderInterface
{ {
public function __construct(private LdapManager $ldapManager, private ?LoggerInterface $logger = null) public function __construct(private LdapManager $ldapManager, private ?LoggerInterface $logger = null)

View File

@@ -727,13 +727,11 @@ class ProjectStatisticService
$endMonth = (clone $startOfWeek)->modify('last day of this month'); $endMonth = (clone $startOfWeek)->modify('last day of this month');
$projectViews = []; $projectViews = [];
foreach ($projects as $project) {
$projectViews[$project->getId()] = new ProjectViewModel($project);
}
$budgetStats = $this->getBudgetStatisticModelForProjects($projects, $today); $budgetStats = $this->getBudgetStatisticModelForProjects($projects, $today);
foreach ($budgetStats as $model) { foreach ($budgetStats as $model) {
$projectViews[$model->getProject()->getId()]->setBudgetStatisticModel($model); $project = $model->getProject();
$projectViews[$project->getId()] = new ProjectViewModel($model);
} }
$projectIds = array_keys($projectViews); $projectIds = array_keys($projectViews);
@@ -741,7 +739,6 @@ class ProjectStatisticService
$tplQb = $this->timesheetRepository->createQueryBuilder('t'); $tplQb = $this->timesheetRepository->createQueryBuilder('t');
$tplQb $tplQb
->select('IDENTITY(t.project) AS id') ->select('IDENTITY(t.project) AS id')
->addSelect('COUNT(t.id) as amount')
->addSelect('COALESCE(SUM(t.duration), 0) AS duration') ->addSelect('COALESCE(SUM(t.duration), 0) AS duration')
->addSelect('COALESCE(SUM(t.rate), 0) AS rate') ->addSelect('COALESCE(SUM(t.rate), 0) AS rate')
->andWhere($tplQb->expr()->in('t.project', ':project')) ->andWhere($tplQb->expr()->in('t.project', ':project'))
@@ -749,14 +746,11 @@ class ProjectStatisticService
->setParameter('project', array_values($projectIds)) ->setParameter('project', array_values($projectIds))
; ;
// find the most recent timesheet for each project
$qb = clone $tplQb; $qb = clone $tplQb;
$qb->addSelect('MAX(t.date) as lastRecord'); $qb->addSelect('MAX(t.date) as lastRecord');
$result = $qb->getQuery()->getScalarResult(); $result = $qb->getQuery()->getScalarResult();
foreach ($result as $row) { foreach ($result as $row) {
$projectViews[$row['id']]->setDurationTotal($row['duration']);
$projectViews[$row['id']]->setRateTotal($row['rate']);
$projectViews[$row['id']]->setTimesheetCounter($row['amount']);
if ($row['lastRecord'] !== null) { if ($row['lastRecord'] !== null) {
// might be the wrong timezone // might be the wrong timezone
$projectViews[$row['id']]->setLastRecord($factory->createDateTime($row['lastRecord'])); $projectViews[$row['id']]->setLastRecord($factory->createDateTime($row['lastRecord']));
@@ -801,34 +795,6 @@ class ProjectStatisticService
$projectViews[$row['id']]->setDurationMonth($row['duration']); $projectViews[$row['id']]->setDurationMonth($row['duration']);
} }
$qb = clone $tplQb;
$qb
->addSelect('t.exported')
->addSelect('t.billable')
->addGroupBy('t.exported')
->addGroupBy('t.billable')
;
$result = $qb->getQuery()->getScalarResult();
foreach ($result as $row) {
/** @var ProjectViewModel $view */
$view = $projectViews[$row['id']];
if ($row['billable'] === 1 && $row['exported'] === 1) {
$view->setBillableDuration($view->getBillableDuration() + $row['duration']);
$view->setBillableRate($view->getBillableRate() + $row['rate']);
} elseif ($row['billable'] === 1 && $row['exported'] === 0) {
$view->setBillableDuration($view->getBillableDuration() + $row['duration']);
$view->setBillableRate($view->getBillableRate() + $row['rate']);
$view->setNotExportedDuration($view->getNotExportedDuration() + $row['duration']);
$view->setNotExportedRate($view->getNotExportedRate() + $row['rate']);
$view->setNotBilledDuration($view->getNotBilledDuration() + $row['duration']);
$view->setNotBilledRate($view->getNotBilledRate() + $row['rate']);
} elseif ($row['billable'] === 0 && $row['exported'] === 0) {
$view->setNotExportedDuration($view->getNotExportedDuration() + $row['duration']);
$view->setNotExportedRate($view->getNotExportedRate() + $row['rate']);
}
// the last possible case $row['billable'] === 0 && $row['exported'] === 1 is extremely unlikely and not used
}
return array_values($projectViews); return array_values($projectViews);
} }
} }

View File

@@ -30,6 +30,7 @@ final class ProjectDateRangeForm extends AbstractType
]); ]);
$builder->add('month', MonthPickerType::class, [ $builder->add('month', MonthPickerType::class, [
'required' => true,
'label' => false, 'label' => false,
'view_timezone' => $options['timezone'], 'view_timezone' => $options['timezone'],
'model_timezone' => $options['timezone'], 'model_timezone' => $options['timezone'],

View File

@@ -14,16 +14,14 @@ use App\Entity\User;
final class ProjectDateRangeQuery final class ProjectDateRangeQuery
{ {
private \DateTime $month; private ?\DateTime $month;
private ?User $user;
private ?Customer $customer = null; private ?Customer $customer = null;
private bool $includeNoWork = false; private bool $includeNoWork = false;
private ?string $budgetType = null; private ?string $budgetType = null;
public function __construct(\DateTime $month, User $user) public function __construct(\DateTime $month, private User $user)
{ {
$this->month = clone $month; $this->month = clone $month;
$this->user = $user;
} }
public function isBudgetIndependent(): bool public function isBudgetIndependent(): bool
@@ -46,7 +44,7 @@ final class ProjectDateRangeQuery
$this->includeNoWork = $includeNoWork; $this->includeNoWork = $includeNoWork;
} }
public function getUser(): ?User public function getUser(): User
{ {
return $this->user; return $this->user;
} }

View File

@@ -15,15 +15,13 @@ use DateTime;
final class ProjectInactiveQuery final class ProjectInactiveQuery
{ {
private DateTime $lastChange; private DateTime $lastChange;
private User $user;
public function __construct(DateTime $lastChange, User $user) public function __construct(DateTime $lastChange, private User $user)
{ {
$this->lastChange = clone $lastChange; $this->lastChange = clone $lastChange;
$this->user = $user;
} }
public function getUser(): ?User public function getUser(): User
{ {
return $this->user; return $this->user;
} }

View File

@@ -11,42 +11,24 @@ namespace App\Reporting\ProjectView;
use App\Entity\Project; use App\Entity\Project;
use App\Model\BudgetStatisticModelInterface; use App\Model\BudgetStatisticModelInterface;
use App\Model\ProjectBudgetStatisticModel;
use App\Model\Statistic\BudgetStatistic;
use DateTime; use DateTime;
final class ProjectViewModel final class ProjectViewModel
{ {
private int $timesheetCounter = 0;
private int $durationDay = 0; private int $durationDay = 0;
private int $durationWeek = 0; private int $durationWeek = 0;
private int $durationMonth = 0; private int $durationMonth = 0;
private int $durationTotal = 0;
private float $rateTotal = 0.00;
private int $notExportedDuration = 0;
private float $notExportedRate = 0.00;
private int $notBilledDuration = 0;
private float $notBilledRate = 0.00;
private int $billableDuration = 0;
private float $billableRate = 0.00;
private ?DateTime $lastRecord = null; private ?DateTime $lastRecord = null;
private ?BudgetStatisticModelInterface $budgetStatisticModel = null;
public function __construct(private Project $project) public function __construct(private ProjectBudgetStatisticModel $budgetStatisticModel)
{ {
} }
public function getProject(): Project public function getProject(): Project
{ {
return $this->project; return $this->budgetStatisticModel->getProject();
}
public function getTimesheetCounter(): int
{
return $this->timesheetCounter;
}
public function setTimesheetCounter(int $timesheetCounter): void
{
$this->timesheetCounter = $timesheetCounter;
} }
public function getDurationDay(): int public function getDurationDay(): int
@@ -79,84 +61,47 @@ final class ProjectViewModel
$this->durationMonth = $durationMonth; $this->durationMonth = $durationMonth;
} }
public function getDurationTotal(): int private function getTotals(): BudgetStatistic
{ {
return $this->durationTotal; if ($this->budgetStatisticModel->getStatisticTotal() === null) {
throw new \InvalidArgumentException('Totals must not be null');
}
return $this->budgetStatisticModel->getStatisticTotal();
} }
public function setDurationTotal(int $durationTotal): void public function getDurationTotal(): int
{ {
$this->durationTotal = $durationTotal; return $this->getTotals()->getDuration();
} }
public function getNotExportedDuration(): int public function getNotExportedDuration(): int
{ {
return $this->notExportedDuration; $totals = $this->getTotals();
}
public function setNotExportedDuration(int $notExportedDuration): void return $totals->getDurationBillable() - $totals->getDurationBillableExported();
{
$this->notExportedDuration = $notExportedDuration;
} }
public function getNotExportedRate(): float public function getNotExportedRate(): float
{ {
return $this->notExportedRate; $totals = $this->getTotals();
}
public function setNotExportedRate(float $notExportedRate): void return $totals->getRateBillable() - $totals->getRateBillableExported();
{
$this->notExportedRate = $notExportedRate;
}
public function getNotBilledDuration(): int
{
return $this->notBilledDuration;
}
public function setNotBilledDuration(int $notBilledDuration): void
{
$this->notBilledDuration = $notBilledDuration;
}
public function getNotBilledRate(): float
{
return $this->notBilledRate;
}
public function setNotBilledRate(float $notBilledRate): void
{
$this->notBilledRate = $notBilledRate;
} }
public function getBillableDuration(): int public function getBillableDuration(): int
{ {
return $this->billableDuration; return $this->getTotals()->getDurationBillable();
}
public function setBillableDuration(int $billableDuration): void
{
$this->billableDuration = $billableDuration;
} }
public function getBillableRate(): float public function getBillableRate(): float
{ {
return $this->billableRate; return $this->getTotals()->getRateBillable();
}
public function setBillableRate(float $billableRate): void
{
$this->billableRate = $billableRate;
} }
public function getRateTotal(): float public function getRateTotal(): float
{ {
return $this->rateTotal; return $this->getTotals()->getRate();
}
public function setRateTotal(float $rateTotal): void
{
$this->rateTotal = $rateTotal;
} }
public function getLastRecord(): ?DateTime public function getLastRecord(): ?DateTime
@@ -173,9 +118,4 @@ final class ProjectViewModel
{ {
return $this->budgetStatisticModel; return $this->budgetStatisticModel;
} }
public function setBudgetStatisticModel(BudgetStatisticModelInterface $budgetStatisticModel): void
{
$this->budgetStatisticModel = $budgetStatisticModel;
}
} }

View File

@@ -23,7 +23,7 @@ final class ProjectViewQuery
{ {
} }
public function getUser(): ?User public function getUser(): User
{ {
return $this->user; return $this->user;
} }

View File

@@ -142,8 +142,12 @@ class BaseQuery
return $this->orderBy; return $this->orderBy;
} }
public function setOrderBy(string $orderBy): self public function setOrderBy(?string $orderBy): self
{ {
if ($orderBy === null) {
$orderBy = (string) $this->defaults['orderBy']; // @phpstan-ignore-line
}
$this->orderBy = $orderBy; $this->orderBy = $orderBy;
return $this; return $this;
@@ -154,8 +158,12 @@ class BaseQuery
return $this->order; return $this->order;
} }
public function setOrder(string $order): self public function setOrder(?string $order): self
{ {
if ($order === null) {
$order = (string) $this->defaults['order']; // @phpstan-ignore-line
}
if (\in_array($order, [self::ORDER_ASC, self::ORDER_DESC])) { if (\in_array($order, [self::ORDER_ASC, self::ORDER_DESC])) {
$this->order = $order; $this->order = $order;
} }

View File

@@ -9,6 +9,9 @@
namespace App\Repository\Query; namespace App\Repository\Query;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\User; use App\Entity\User;
class TeamQuery extends BaseQuery class TeamQuery extends BaseQuery
@@ -19,12 +22,27 @@ class TeamQuery extends BaseQuery
* @var User[] * @var User[]
*/ */
private array $users = []; private array $users = [];
/**
* @var array<Customer>
*/
private array $customers = [];
/**
* @var array<Project>
*/
private array $projects = [];
/**
* @var array<Activity>
*/
private array $activities = [];
public function __construct() public function __construct()
{ {
$this->setDefaults([ $this->setDefaults([
'orderBy' => 'name', 'orderBy' => 'name',
'users' => [], 'users' => [],
'customers' => [],
'projects' => [],
'activities' => [],
]); ]);
} }
@@ -33,20 +51,16 @@ class TeamQuery extends BaseQuery
return !empty($this->users); return !empty($this->users);
} }
public function addUser(User $user): self public function addUser(User $user): void
{ {
$this->users[$user->getId()] = $user; $this->users[$user->getId()] = $user;
return $this;
} }
public function removeUser(User $user): self public function removeUser(User $user): void
{ {
if (isset($this->users[$user->getId()])) { if (isset($this->users[$user->getId()])) {
unset($this->users[$user->getId()]); unset($this->users[$user->getId()]);
} }
return $this;
} }
/** /**
@@ -56,4 +70,82 @@ class TeamQuery extends BaseQuery
{ {
return array_values($this->users); return array_values($this->users);
} }
public function hasCustomers(): bool
{
return \count($this->customers) > 0;
}
/**
* @return Customer[]
*/
public function getCustomers(): array
{
return $this->customers;
}
/**
* @param array<Customer> $customers
*/
public function setCustomers(array $customers): void
{
$this->customers = $customers;
}
public function addCustomer(Customer $customer): void
{
$this->customers[] = $customer;
}
public function hasProjects(): bool
{
return \count($this->projects) > 0;
}
/**
* @return Project[]
*/
public function getProjects(): array
{
return $this->projects;
}
/**
* @param array<Project> $projects
*/
public function setProjects(array $projects): void
{
$this->projects = $projects;
}
public function addProject(Project $project): void
{
$this->projects[] = $project;
}
public function hasActivities(): bool
{
return \count($this->activities) > 0;
}
/**
* @return Activity[]
*/
public function getActivities(): array
{
return $this->activities;
}
/**
* @param array<Activity> $activities
*/
public function setActivities(array $activities): void
{
$this->activities = $activities;
}
public function addActivity(Activity $activity): void
{
$this->activities[] = $activity;
}
} }

View File

@@ -148,12 +148,9 @@ class TeamRepository extends EntityRepository
private function getQueryBuilderForQuery(TeamQuery $query): QueryBuilder private function getQueryBuilderForQuery(TeamQuery $query): QueryBuilder
{ {
$qb = $this->getEntityManager()->createQueryBuilder(); $qb = $this->createQueryBuilder('t');
$qb $qb->select('t');
->select('t')
->from(Team::class, 't')
;
$orderBy = $query->getOrderBy(); $orderBy = $query->getOrderBy();
switch ($orderBy) { switch ($orderBy) {
@@ -162,6 +159,30 @@ class TeamRepository extends EntityRepository
break; break;
} }
if ($query->hasCustomers()) {
$qb->leftJoin('t.customers', 'qCustomers');
$qb->orWhere(
$qb->expr()->in('qCustomers', ':customers')
);
$qb->setParameter('customers', $query->getCustomers());
}
if ($query->hasProjects()) {
$qb->leftJoin('t.projects', 'qProjects');
$qb->orWhere(
$qb->expr()->in('qProjects', ':projects')
);
$qb->setParameter('projects', $query->getProjects());
}
if ($query->hasActivities()) {
$qb->leftJoin('t.activities', 'qActivities');
$qb->orWhere(
$qb->expr()->in('qActivities', ':activities')
);
$qb->setParameter('activities', $query->getActivities());
}
if ($query->hasUsers()) { if ($query->hasUsers()) {
$qb->leftJoin('t.members', 'qMembers'); $qb->leftJoin('t.members', 'qMembers');
$qb->orWhere( $qb->orWhere(

View File

@@ -35,6 +35,7 @@ use Symfony\Component\Security\Core\User\UserProviderInterface;
/** /**
* @extends \Doctrine\ORM\EntityRepository<User> * @extends \Doctrine\ORM\EntityRepository<User>
* @template-implements PasswordUpgraderInterface<User> * @template-implements PasswordUpgraderInterface<User>
* @template-implements UserProviderInterface<User>
*/ */
class UserRepository extends EntityRepository implements UserLoaderInterface, UserProviderInterface, PasswordUpgraderInterface class UserRepository extends EntityRepository implements UserLoaderInterface, UserProviderInterface, PasswordUpgraderInterface
{ {

View File

@@ -18,6 +18,9 @@ use Symfony\Component\Security\Core\User\UserProviderInterface;
final class SamlProvider final class SamlProvider
{ {
/**
* @param UserProviderInterface<User> $userProvider
*/
public function __construct( public function __construct(
private UserRepository $repository, private UserRepository $repository,
private UserProviderInterface $userProvider, private UserProviderInterface $userProvider,
@@ -30,8 +33,10 @@ final class SamlProvider
$user = null; $user = null;
try { try {
/** @var User $user */ if ($token->getUserIdentifier() !== null) {
$user = $this->userProvider->loadUserByIdentifier($token->getUserIdentifier()); /** @var User $user */
$user = $this->userProvider->loadUserByIdentifier($token->getUserIdentifier());
}
} catch (UserNotFoundException $e) { } catch (UserNotFoundException $e) {
} }

View File

@@ -20,13 +20,14 @@ use Symfony\Component\Security\Core\User\UserProviderInterface;
/** /**
* @template-implements PasswordUpgraderInterface<User> * @template-implements PasswordUpgraderInterface<User>
* @template-implements UserProviderInterface<User>
*/ */
final class KimaiUserProvider implements UserProviderInterface, PasswordUpgraderInterface final class KimaiUserProvider implements UserProviderInterface, PasswordUpgraderInterface
{ {
private ?ChainUserProvider $provider = null; private ?ChainUserProvider $provider = null;
/** /**
* @param iterable|UserProviderInterface[] $providers * @param iterable<UserProviderInterface<User>> $providers
*/ */
public function __construct(private iterable $providers, private SystemConfiguration $configuration) public function __construct(private iterable $providers, private SystemConfiguration $configuration)
{ {
@@ -60,12 +61,12 @@ final class KimaiUserProvider implements UserProviderInterface, PasswordUpgrader
public function loadUserByIdentifier(string $identifier): UserInterface public function loadUserByIdentifier(string $identifier): UserInterface
{ {
return $this->getInternalProvider()->loadUserByIdentifier($identifier); return $this->getInternalProvider()->loadUserByIdentifier($identifier); // @phpstan-ignore-line
} }
public function refreshUser(UserInterface $user): UserInterface public function refreshUser(UserInterface $user): UserInterface
{ {
return $this->getInternalProvider()->refreshUser($user); return $this->getInternalProvider()->refreshUser($user); // @phpstan-ignore-line
} }
public function supportsClass(string $class): bool public function supportsClass(string $class): bool

View File

@@ -16,7 +16,6 @@ use App\Event\PageActionsEvent;
use App\Event\ThemeEvent; use App\Event\ThemeEvent;
use App\Event\ThemeJavascriptTranslationsEvent; use App\Event\ThemeJavascriptTranslationsEvent;
use App\Utils\Color; use App\Utils\Color;
use App\Utils\FormFormatConverter;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
@@ -110,11 +109,8 @@ final class ThemeExtension implements RuntimeExtensionInterface
return (new Color())->getRandom($identifier); return (new Color())->getRandom($identifier);
} }
public function getTimePresets(string $timezone, string $format): array public function getTimePresets(string $timezone): array
{ {
$converter = new FormFormatConverter();
$format = $converter->convert($format);
$intervalMinutes = $this->configuration->getTimesheetIncrementMinutes(); $intervalMinutes = $this->configuration->getTimesheetIncrementMinutes();
if ($intervalMinutes < 5) { if ($intervalMinutes < 5) {
@@ -123,17 +119,15 @@ final class ThemeExtension implements RuntimeExtensionInterface
$maxMinutes = 24 * 60 - $intervalMinutes; $maxMinutes = 24 * 60 - $intervalMinutes;
$date = new \DateTime('now', new \DateTimeZone($timezone)); $date = new \DateTimeImmutable('now', new \DateTimeZone($timezone));
$date->setTime(0, 0, 0); $date = $date->setTime(0, 0, 0);
$presets = [ $presets = [$date];
$date->format($format)
];
for ($minutes = $intervalMinutes; $minutes <= $maxMinutes; $minutes += $intervalMinutes) { for ($minutes = $intervalMinutes; $minutes <= $maxMinutes; $minutes += $intervalMinutes) {
$date->modify('+' . $intervalMinutes . ' minutes'); $date = $date->modify('+' . $intervalMinutes . ' minutes');
$presets[] = $date->format($format); $presets[] = $date;
} }
return $presets; return $presets;

View File

@@ -127,7 +127,7 @@ class UserService
return $user; return $user;
} }
public function findUserByUsernameOrEmail(string $usernameOrEmail): ?User public function findUserByUsernameOrEmail(string $usernameOrEmail): User
{ {
return $this->repository->loadUserByIdentifier($usernameOrEmail); return $this->repository->loadUserByIdentifier($usernameOrEmail);
} }

View File

@@ -57,14 +57,14 @@ final class SearchTerm
return $this->fields; return $this->fields;
} }
public function getSearchTerm(): ?string public function getSearchTerm(): string
{ {
return $this->term; return $this->term;
} }
public function hasSearchTerm(): bool public function hasSearchTerm(): bool
{ {
return !empty($this->term); return $this->term !== '';
} }
public function getOriginalSearch(): string public function getOriginalSearch(): string

View File

@@ -22,7 +22,7 @@ final class Customer extends Constraint
public string $message = 'This customer has invalid settings.'; public string $message = 'This customer has invalid settings.';
public function getTargets(): string|array public function getTargets(): string
{ {
return self::CLASS_CONSTRAINT; return self::CLASS_CONSTRAINT;
} }

View File

@@ -22,7 +22,7 @@ final class DateTimeFormat extends Constraint
public ?string $separator = null; public ?string $separator = null;
public ?string $message = 'This datetime format is invalid.'; public ?string $message = 'This datetime format is invalid.';
public function getTargets(): string|array public function getTargets(): string
{ {
return self::PROPERTY_CONSTRAINT; return self::PROPERTY_CONSTRAINT;
} }

View File

@@ -22,7 +22,7 @@ final class Project extends Constraint
public string $message = 'This project has invalid settings.'; public string $message = 'This project has invalid settings.';
public function getTargets(): string|array public function getTargets(): string
{ {
return self::CLASS_CONSTRAINT; return self::CLASS_CONSTRAINT;
} }

View File

@@ -22,7 +22,7 @@ final class Team extends Constraint
public string $message = 'The team has invalid settings.'; public string $message = 'The team has invalid settings.';
public function getTargets(): string|array public function getTargets(): string
{ {
return self::CLASS_CONSTRAINT; return self::CLASS_CONSTRAINT;
} }

View File

@@ -21,7 +21,7 @@ final class TimeFormat extends Constraint
public string $message = 'This time format is invalid.'; public string $message = 'This time format is invalid.';
public function getTargets(): string|array public function getTargets(): string
{ {
return self::PROPERTY_CONSTRAINT; return self::PROPERTY_CONSTRAINT;
} }

View File

@@ -16,7 +16,7 @@ final class Timesheet extends Constraint
{ {
public string $message = 'This timesheet has invalid settings.'; public string $message = 'This timesheet has invalid settings.';
public function getTargets(): string|array public function getTargets(): string
{ {
return self::CLASS_CONSTRAINT; return self::CLASS_CONSTRAINT;
} }

View File

@@ -33,7 +33,7 @@ final class TimesheetBasic extends TimesheetConstraint
public string $message = 'This timesheet has invalid settings.'; public string $message = 'This timesheet has invalid settings.';
public function getTargets(): string|array public function getTargets(): string
{ {
return self::CLASS_CONSTRAINT; return self::CLASS_CONSTRAINT;
} }

View File

@@ -24,7 +24,7 @@ final class TimesheetDeactivated extends TimesheetConstraint
public string $message = 'This timesheet has invalid settings.'; public string $message = 'This timesheet has invalid settings.';
public function getTargets(): string|array public function getTargets(): string
{ {
return self::CLASS_CONSTRAINT; return self::CLASS_CONSTRAINT;
} }

View File

@@ -24,7 +24,7 @@ final class TimesheetExported extends TimesheetConstraint
*/ */
public null|\DateTime|string $now; public null|\DateTime|string $now;
public function getTargets(): string|array public function getTargets(): string
{ {
return self::CLASS_CONSTRAINT; return self::CLASS_CONSTRAINT;
} }

View File

@@ -22,7 +22,7 @@ final class TimesheetFutureTimes extends TimesheetConstraint
public string $message = 'The date cannot be in the future.'; public string $message = 'The date cannot be in the future.';
public function getTargets(): string|array public function getTargets(): string
{ {
return self::CLASS_CONSTRAINT; return self::CLASS_CONSTRAINT;
} }

View File

@@ -23,7 +23,7 @@ final class TimesheetLockdown extends TimesheetConstraint
*/ */
public \DateTime|string|null $now; public \DateTime|string|null $now;
public function getTargets(): string|array public function getTargets(): string
{ {
return self::CLASS_CONSTRAINT; return self::CLASS_CONSTRAINT;
} }

View File

@@ -22,7 +22,7 @@ final class TimesheetLongRunning extends TimesheetConstraint
public string $message = 'Maximum duration of {{ value }} hours exceeded.'; public string $message = 'Maximum duration of {{ value }} hours exceeded.';
public string $maximumMessage = 'Maximum duration exceeded.'; public string $maximumMessage = 'Maximum duration exceeded.';
public function getTargets(): string|array public function getTargets(): string
{ {
return self::CLASS_CONSTRAINT; return self::CLASS_CONSTRAINT;
} }

View File

@@ -34,7 +34,7 @@ final class TimesheetMultiUpdate extends Constraint
public string $message = 'This form has invalid settings.'; public string $message = 'This form has invalid settings.';
public function getTargets(): string|array public function getTargets(): string
{ {
return self::CLASS_CONSTRAINT; return self::CLASS_CONSTRAINT;
} }

View File

@@ -22,7 +22,7 @@ final class TimesheetMultiUser extends Constraint
public string $message = 'This form has invalid settings.'; public string $message = 'This form has invalid settings.';
public function getTargets(): string|array public function getTargets(): string
{ {
return self::CLASS_CONSTRAINT; return self::CLASS_CONSTRAINT;
} }

View File

@@ -19,7 +19,7 @@ final class TimesheetOverlapping extends TimesheetConstraint
public string $message = 'You already have an entry for this time.'; public string $message = 'You already have an entry for this time.';
public function getTargets(): string|array public function getTargets(): string
{ {
return self::CLASS_CONSTRAINT; return self::CLASS_CONSTRAINT;
} }

View File

@@ -19,7 +19,7 @@ final class TimesheetRestart extends TimesheetConstraint
public string $message = 'You are not allowed to start this timesheet record.'; public string $message = 'You are not allowed to start this timesheet record.';
public function getTargets(): string|array public function getTargets(): string
{ {
return self::CLASS_CONSTRAINT; return self::CLASS_CONSTRAINT;
} }

View File

@@ -19,7 +19,7 @@ final class TimesheetZeroDuration extends TimesheetConstraint
public string $message = 'Duration cannot be zero.'; public string $message = 'Duration cannot be zero.';
public function getTargets(): string|array public function getTargets(): string
{ {
return self::CLASS_CONSTRAINT; return self::CLASS_CONSTRAINT;
} }

View File

@@ -28,7 +28,7 @@ final class User extends Constraint
public string $message = 'The user has invalid settings.'; public string $message = 'The user has invalid settings.';
public function getTargets(): string|array public function getTargets(): string
{ {
return self::CLASS_CONSTRAINT; return self::CLASS_CONSTRAINT;
} }

View File

@@ -88,7 +88,7 @@
{% endif %} {% endif %}
{% if year is defined %} {% if year is defined %}
{% embed '@theme/embeds/collapsible.html.twig' with {open: not box_configuration.collapsed, id: 'working_time_detail_box', border: false, item: {options: {bodyExtraClass: 'border-top'}, bodyClasses: 'p-0 table-responsive'}} %} {% embed '@theme/embeds/collapsible.html.twig' with {id: 'working_time_detail_box', border: false, item: {options: {bodyExtraClass: 'border-top', open: not box_configuration.collapsed}, bodyClasses: 'p-0 table-responsive'}} %}
{% set yearShould = 0 %} {% set yearShould = 0 %}
{% set yearIs = 0 %} {% set yearIs = 0 %}
{% set approved = 0 %} {% set approved = 0 %}
@@ -228,7 +228,7 @@
{% if withWorkHourConfiguration %} {% if withWorkHourConfiguration %}
{% set expectedWeekTimes = user.workHoursMonday + user.workHoursTuesday + user.workHoursWednesday + user.workHoursThursday + user.workHoursFriday + user.workHoursSaturday + user.workHoursSunday %} {% set expectedWeekTimes = user.workHoursMonday + user.workHoursTuesday + user.workHoursWednesday + user.workHoursThursday + user.workHoursFriday + user.workHoursSaturday + user.workHoursSunday %}
{% embed '@theme/embeds/collapsible.html.twig' with {open: not box_configuration.collapsed, id: 'work_contract_should_preview', border: false, item: {options: {bodyExtraClass: 'border-top'}}} %} {% embed '@theme/embeds/collapsible.html.twig' with {id: 'work_contract_should_preview', border: false, item: {options: {bodyExtraClass: 'border-top'}}} %}
{% from "macros/status.html.twig" import status_duration %} {% from "macros/status.html.twig" import status_duration %}
{% block title %} {% block title %}
{{ 'work_times_should'|trans }} {{ 'work_times_should'|trans }}

View File

@@ -15,14 +15,14 @@
<table class="table table-hover dataTable"> <table class="table table-hover dataTable">
<tr> <tr>
<td>{{ 'timeBudget'|trans }}</td> <td>{{ 'timeBudget'|trans }}</td>
<td class="text-nowrap text-end"> <td class="text-nowrap text-end w-min">
{% if entity.timeBudget > 0 %} {% if entity.timeBudget > 0 %}
{{ entity.timeBudget|duration }} {{ entity.timeBudget|duration }}
{% else %} {% else %}
- -
{% endif %} {% endif %}
</td> </td>
<td class="text-nowrap text-end"> <td class="text-nowrap text-end w-min">
{% if entity.timeBudget > 0 %} {% if entity.timeBudget > 0 %}
100% 100%
{% else %} {% else %}
@@ -48,6 +48,18 @@
<td class="text-nowrap text-end">{{ stats.durationBillable|duration }}</td> <td class="text-nowrap text-end">{{ stats.durationBillable|duration }}</td>
<td class="text-nowrap text-end">{{ percentReached|number_format(2) }}%</td> <td class="text-nowrap text-end">{{ percentReached|number_format(2) }}%</td>
</tr> </tr>
<tr>
<td>{{ 'not_exported'|trans }}</td>
<td class="text-nowrap text-end">
{% set not_exported = (stats.statisticTotal.durationBillable - stats.statisticTotal.durationBillableExported) %}
{% if export_url is defined and export_url is not null %}
<a href="{{ export_url }}">{{ not_exported|duration }}</a>
{% else %}
{{ not_exported|duration }}
{% endif %}
</td>
<td class="text-nowrap text-end">-</td>
</tr>
{% if entity.timeBudget > 0 %} {% if entity.timeBudget > 0 %}
<tr> <tr>
<th colspan="3" class="text-nowrap text-end"> <th colspan="3" class="text-nowrap text-end">
@@ -87,14 +99,14 @@
<table class="table table-hover dataTable"> <table class="table table-hover dataTable">
<tr> <tr>
<td>{{ 'budget'|trans }}</td> <td>{{ 'budget'|trans }}</td>
<td class="text-nowrap text-end"> <td class="text-nowrap text-end w-min">
{% if entity.budget > 0 %} {% if entity.budget > 0 %}
{{ entity.budget|money(currency) }} {{ entity.budget|money(currency) }}
{% else %} {% else %}
- -
{% endif %} {% endif %}
</td> </td>
<td class="text-nowrap text-end"> <td class="text-nowrap text-end w-min">
{% if entity.budget > 0 %} {% if entity.budget > 0 %}
100% 100%
{% else %} {% else %}
@@ -120,6 +132,18 @@
<td class="text-nowrap text-end">{{ stats.rateBillable|money(currency) }}</td> <td class="text-nowrap text-end">{{ stats.rateBillable|money(currency) }}</td>
<td class="text-nowrap text-end">{{ percentReached|number_format(2) }}%</td> <td class="text-nowrap text-end">{{ percentReached|number_format(2) }}%</td>
</tr> </tr>
<tr>
<td>{{ 'not_invoiced'|trans }}</td>
<td class="text-nowrap text-end">
{% set not_exported = (stats.statisticTotal.rateBillable - stats.statisticTotal.rateBillableExported) %}
{% if invoice_url is defined and invoice_url is not null %}
<a href="{{ invoice_url }}">{{ not_exported|money(currency) }}</a>
{% else %}
{{ not_exported|money(currency) }}
{% endif %}
</td>
<td class="text-nowrap text-end">-</td>
</tr>
{% set percentReached = 0 %} {% set percentReached = 0 %}
{% if stats.rateBillable > 0 %} {% if stats.rateBillable > 0 %}
{% set percentReached = (stats.internalRate / (stats.rateBillable / 100)) %} {% set percentReached = (stats.internalRate / (stats.rateBillable / 100)) %}

View File

@@ -147,7 +147,7 @@
<a href="#" data-form-widget="date-now" data-format="{{ jsFormat }}" data-target="{{ id }}">{{ icon('calendar') }}</a> <a href="#" data-form-widget="date-now" data-format="{{ jsFormat }}" data-target="{{ id }}">{{ icon('calendar') }}</a>
</span> </span>
{{ block('form_widget_simple') }} {{ block('form_widget_simple') }}
{% set time_presets = form_time_presets(app.user.timezone, format) %} {% set time_presets = form_time_presets(app.user.timezone) %}
{% if time_presets|length > 0 and form.vars.disabled is same as (false) %} {% if time_presets|length > 0 and form.vars.disabled is same as (false) %}
<button type="button" class="input-group-text dropdown-toggle btn-duration-preset" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></button> <button type="button" class="input-group-text dropdown-toggle btn-duration-preset" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></button>
<div class="dropdown-menu dropdown-menu-end pre-scrollable"> <div class="dropdown-menu dropdown-menu-end pre-scrollable">
@@ -155,6 +155,7 @@
{% for index in [0, 1, 2, 3] %} {% for index in [0, 1, 2, 3] %}
<div class="dropdown-menu-column" style="min-width: 4rem"> <div class="dropdown-menu-column" style="min-width: 4rem">
{% for value in time_presets %} {% for value in time_presets %}
{% set value = value|time %}
{% if loop.index0 % 4 == index %} {% if loop.index0 % 4 == index %}
<a class="dropdown-item justify-content-center" href="#" data-form-widget="copy-data" data-target="#{{ form.vars.id }}" data-value="{{ value }}" data-event="change">{{ value }}</a> <a class="dropdown-item justify-content-center" href="#" data-form-widget="copy-data" data-target="#{{ form.vars.id }}" data-value="{{ value }}" data-event="change">{{ value }}</a>
{% endif %} {% endif %}

View File

@@ -11,32 +11,30 @@
{% set help = null %} {% set help = null %}
<div class="page-actions"> <div class="page-actions">
<div class="pa-desktop d-none d-sm-inline-flex btn-list"> <div class="pa-desktop d-none d-sm-inline-flex btn-list">
{%- apply spaceless -%} {%- for icon, values in actions %}
{%- for icon, values in actions %} {% if 'help' in icon %}
{% if 'help' in icon %} {% set help = values %}
{% set help = values %} {% elseif 'divider' in icon and values is null %}
{% elseif 'divider' in icon and values is null %} {# what to do here ? #}
{# what to do here ? #} {% else %}
{% else %} {% if values.children is defined and values.children|length > 0 %}
{% if values.children is defined and values.children|length > 0 %} <div class="dropdown">
<div class="dropdown"> <button type="button" class="btn {{ btnClasses}} dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<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({}, 'actions') }}{% endif %}
{{ icon(icon, true) }}{% if large %} {{ values.title is defined ? values.title|trans : icon|trans({}, 'actions') }}{% endif %} </button>
</button> <div class="dropdown-menu">
<div class="dropdown-menu"> {%- for icon, values in values.children %}
{%- for icon, values in values.children %} {% set values = values|merge({class: 'dropdown-item action-' ~ icon ~ ' ' ~ (values.class|default(''))}) %}
{% set values = values|merge({class: 'dropdown-item action-' ~ icon ~ ' ' ~ (values.class|default(''))}) %} {{ _self.action_button(false, values, false) }}
{{ _self.action_button(false, values, false) }} {% endfor %}
{% endfor %}
</div>
</div> </div>
{% else %} </div>
{% set values = values|merge({combined: large, class: btnClasses ~ ' action-' ~ icon ~ ' ' ~ (values.class|default(''))}) %} {% else %}
{{ _self.action_button(icon, values) }} {% set values = values|merge({combined: large, class: btnClasses ~ ' action-' ~ icon ~ ' ' ~ (values.class|default(''))}) %}
{% endif %} {{ _self.action_button(icon, values) }}
{% endif %} {% endif %}
{% endfor -%} {% endif %}
{% endapply %} {% endfor -%}
{% if help is not null %} {% if help is not null %}
{% set help = help|merge({class: helpClasses ~ ' action-help ' ~ (help.class|default(''))}) %} {% set help = help|merge({class: helpClasses ~ ' action-help ' ~ (help.class|default(''))}) %}
{{ _self.action_button('help', help) }} {{ _self.action_button('help', help) }}
@@ -191,14 +189,12 @@
{# for @internal use only #} {# for @internal use only #}
{% macro label_color_dot(type, isVisible, name, url, color) %} {% macro label_color_dot(type, isVisible, name, url, color) %}
{% apply spaceless %}
{%- if url is not empty -%}<a href="{{ url }}">{% endif %} {%- if url is not empty -%}<a href="{{ url }}">{% endif %}
<span class="pe-1 label-{{ type }}{{ isVisible is same as (false) ? ' label-invisible' : '' }}"> <span class="pe-1 label-{{ type }}{{ isVisible is same as (false) ? ' label-invisible' : '' }}">
<span class="badge me-1" {% if color is not empty %} style="background-color:{{ color }}"{% endif %}></span> <span class="badge me-1" {% if color is not empty %} style="background-color:{{ color }}"{% endif %}></span>
{{- name -}} {{- name -}}
</span> </span>
{%- if url is not empty -%}</a>{% endif %} {%- if url is not empty -%}</a>{% endif %}
{% endapply %}
{% endmacro %} {% endmacro %}
{% macro badge_team_access(teams) %} {% macro badge_team_access(teams) %}

View File

@@ -395,7 +395,7 @@
</div> </div>
<div class="datagrid-content"> <div class="datagrid-content">
<a href="{{ path('invoice', {'customers[]': project.customer.id, 'projects[]': project.id, 'daterange': ''}) }}"> <a href="{{ path('invoice', {'customers[]': project.customer.id, 'projects[]': project.id, 'daterange': ''}) }}">
{{ project_view.notBilledRate|money(currency) }} {{ project_view.notExportedRate|money(currency) }}
</a> </a>
</div> </div>
</div> </div>

View File

@@ -110,7 +110,7 @@
</a> </a>
{% elseif name == 'invoiced' %} {% elseif name == 'invoiced' %}
<a href="{{ path('invoice', {'customers[]': project.customer.id, 'projects[]': project.id, 'daterange': ''}) }}"> <a href="{{ path('invoice', {'customers[]': project.customer.id, 'projects[]': project.id, 'daterange': ''}) }}">
{{ entry.notBilledRate|money(currency) }} {{ entry.notExportedRate|money(currency) }}
</a> </a>
{% elseif name == 'projectStart' %} {% elseif name == 'projectStart' %}
{% if project.start is not null %}{{ project.start|date_short }}{% endif %} {% if project.start is not null %}{{ project.start|date_short }}{% endif %}

View File

@@ -18,10 +18,15 @@ use PHPUnit\Framework\TestCase;
*/ */
class PrepareUserEventTest extends TestCase class PrepareUserEventTest extends TestCase
{ {
public function testGetterAndSetter() public function testGetterAndSetter(): void
{ {
$user = new User(); $user = new User();
$sut = new PrepareUserEvent($user); $sut = new PrepareUserEvent($user);
$this->assertSame($user, $sut->getUser()); $this->assertSame($user, $sut->getUser());
$this->assertTrue($sut->isBooting());
$sut = new PrepareUserEvent($user, false);
$this->assertSame($user, $sut->getUser());
$this->assertFalse($sut->isBooting());
} }
} }

View File

@@ -19,7 +19,7 @@ use PHPUnit\Framework\TestCase;
*/ */
class UserPreferenceEventTest extends TestCase class UserPreferenceEventTest extends TestCase
{ {
public function testGetterAndSetter() public function testGetterAndSetter(): void
{ {
$user = new User(); $user = new User();
$user->setAlias('foo'); $user->setAlias('foo');
@@ -28,14 +28,18 @@ class UserPreferenceEventTest extends TestCase
$sut = new UserPreferenceEvent($user, []); $sut = new UserPreferenceEvent($user, []);
$this->assertEquals($user, $sut->getUser()); $this->assertEquals($user, $sut->getUser());
$this->assertTrue($sut->isBooting());
$this->assertEquals([], $sut->getPreferences()); $this->assertEquals([], $sut->getPreferences());
$sut->addPreference($pref); $sut->addPreference($pref);
$this->assertEquals([$pref], $sut->getPreferences()); $this->assertEquals([$pref], $sut->getPreferences());
$sut = new UserPreferenceEvent($user, [], false);
$this->assertFalse($sut->isBooting());
} }
public function testDuplicatePreferenceThrowsException() public function testDuplicatePreferenceThrowsException(): void
{ {
$this->expectException(\InvalidArgumentException::class); $this->expectException(\InvalidArgumentException::class);

View File

@@ -10,6 +10,8 @@
namespace App\Tests\Reporting\ProjectView; namespace App\Tests\Reporting\ProjectView;
use App\Entity\Project; use App\Entity\Project;
use App\Model\ProjectBudgetStatisticModel;
use App\Model\Statistic\BudgetStatistic;
use App\Reporting\ProjectView\ProjectViewModel; use App\Reporting\ProjectView\ProjectViewModel;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -18,10 +20,13 @@ use PHPUnit\Framework\TestCase;
*/ */
class ProjectViewModelTest extends TestCase class ProjectViewModelTest extends TestCase
{ {
public function testDefaults() public function testDefaults(): void
{ {
$project = new Project(); $project = new Project();
$sut = new ProjectViewModel($project); $model = new ProjectBudgetStatisticModel($project);
$total = new BudgetStatistic();
$model->setStatisticTotal($total);
$sut = new ProjectViewModel($model);
self::assertSame($project, $sut->getProject()); self::assertSame($project, $sut->getProject());
self::assertSame(0, $sut->getDurationDay()); self::assertSame(0, $sut->getDurationDay());
@@ -32,27 +37,33 @@ class ProjectViewModelTest extends TestCase
self::assertSame(0.0, $sut->getNotExportedRate()); self::assertSame(0.0, $sut->getNotExportedRate());
self::assertSame(0.0, $sut->getRateTotal()); self::assertSame(0.0, $sut->getRateTotal());
self::assertNull($sut->getLastRecord()); self::assertNull($sut->getLastRecord());
self::assertSame(0, $sut->getTimesheetCounter());
self::assertNull($sut->getLastRecord()); self::assertNull($sut->getLastRecord());
self::assertSame(0.0, $sut->getBillableRate()); self::assertSame(0.0, $sut->getBillableRate());
self::assertSame(0, $sut->getBillableDuration()); self::assertSame(0, $sut->getBillableDuration());
self::assertSame(0, $sut->getNotBilledDuration());
self::assertSame(0.0, $sut->getNotBilledRate());
} }
public function testSetterGetter() public function testSetterGetter(): void
{ {
$sut = new ProjectViewModel(new Project()); $project = new Project();
$model = new ProjectBudgetStatisticModel($project);
$total = new BudgetStatistic();
$total->setCounter(123);
$total->setDuration(3456789);
$total->setDurationBillable(3456789);
$total->setDurationBillableExported(3400000);
$total->setRate(789.0);
$total->setRateBillable(16789.0);
$total->setRateBillableExported(10000.0);
$model->setStatisticTotal($total);
$sut = new ProjectViewModel($model);
$date = new \DateTime(); $date = new \DateTime();
$sut->setDurationDay(123456789); $sut->setDurationDay(123456789);
$sut->setDurationMonth(23456789); $sut->setDurationMonth(23456789);
$sut->setDurationTotal(3456789);
$sut->setDurationWeek(456789); $sut->setDurationWeek(456789);
$sut->setNotExportedDuration(56789);
$sut->setNotExportedRate(6789);
$sut->setRateTotal(789);
$sut->setLastRecord($date); $sut->setLastRecord($date);
self::assertSame(123456789, $sut->getDurationDay()); self::assertSame(123456789, $sut->getDurationDay());
@@ -65,18 +76,10 @@ class ProjectViewModelTest extends TestCase
self::assertSame($date, $sut->getLastRecord()); self::assertSame($date, $sut->getLastRecord());
$date = new \DateTime(); $date = new \DateTime();
$sut->setTimesheetCounter(123);
$sut->setLastRecord($date); $sut->setLastRecord($date);
$sut->setBillableRate(123.456);
$sut->setBillableDuration(321);
$sut->setNotBilledDuration(9876);
$sut->setNotBilledRate(4705.23);
self::assertSame(123, $sut->getTimesheetCounter());
self::assertSame($date, $sut->getLastRecord()); self::assertSame($date, $sut->getLastRecord());
self::assertSame(123.456, $sut->getBillableRate()); self::assertSame(16789.0, $sut->getBillableRate());
self::assertSame(321, $sut->getBillableDuration()); self::assertSame(3456789, $sut->getBillableDuration());
self::assertSame(9876, $sut->getNotBilledDuration());
self::assertSame(4705.23, $sut->getNotBilledRate());
} }
} }

View File

@@ -4477,11 +4477,6 @@ parameters:
count: 5 count: 5
path: Event/PermissionsEventTest.php path: Event/PermissionsEventTest.php
-
message: "#^Method App\\\\Tests\\\\Event\\\\PrepareUserEventTest\\:\\:testGetterAndSetter\\(\\) has no return type specified\\.$#"
count: 1
path: Event/PrepareUserEventTest.php
- -
message: "#^Method App\\\\Tests\\\\Event\\\\ProjectBudgetStatisticEventTest\\:\\:testStatistic\\(\\) has no return type specified\\.$#" message: "#^Method App\\\\Tests\\\\Event\\\\ProjectBudgetStatisticEventTest\\:\\:testStatistic\\(\\) has no return type specified\\.$#"
count: 1 count: 1
@@ -4607,16 +4602,6 @@ parameters:
count: 1 count: 1
path: Event/UserPreferenceDisplayEventTest.php path: Event/UserPreferenceDisplayEventTest.php
-
message: "#^Method App\\\\Tests\\\\Event\\\\UserPreferenceEventTest\\:\\:testDuplicatePreferenceThrowsException\\(\\) has no return type specified\\.$#"
count: 1
path: Event/UserPreferenceEventTest.php
-
message: "#^Method App\\\\Tests\\\\Event\\\\UserPreferenceEventTest\\:\\:testGetterAndSetter\\(\\) has no return type specified\\.$#"
count: 1
path: Event/UserPreferenceEventTest.php
- -
message: "#^Method App\\\\Tests\\\\Event\\\\UserRevenueStatisticEventTest\\:\\:testDefaultValues\\(\\) has no return type specified\\.$#" message: "#^Method App\\\\Tests\\\\Event\\\\UserRevenueStatisticEventTest\\:\\:testDefaultValues\\(\\) has no return type specified\\.$#"
count: 1 count: 1
@@ -7137,16 +7122,6 @@ parameters:
count: 1 count: 1
path: Reporting/ProjectInactive/ProjectInactiveQueryTest.php path: Reporting/ProjectInactive/ProjectInactiveQueryTest.php
-
message: "#^Method App\\\\Tests\\\\Reporting\\\\ProjectView\\\\ProjectViewModelTest\\:\\:testDefaults\\(\\) has no return type specified\\.$#"
count: 1
path: Reporting/ProjectView/ProjectViewModelTest.php
-
message: "#^Method App\\\\Tests\\\\Reporting\\\\ProjectView\\\\ProjectViewModelTest\\:\\:testSetterGetter\\(\\) has no return type specified\\.$#"
count: 1
path: Reporting/ProjectView/ProjectViewModelTest.php
- -
message: "#^Method App\\\\Tests\\\\Reporting\\\\ProjectView\\\\ProjectViewQueryTest\\:\\:testDefaults\\(\\) has no return type specified\\.$#" message: "#^Method App\\\\Tests\\\\Reporting\\\\ProjectView\\\\ProjectViewQueryTest\\:\\:testDefaults\\(\\) has no return type specified\\.$#"
count: 1 count: 1
@@ -8457,11 +8432,6 @@ parameters:
count: 1 count: 1
path: TranslationsTest.php path: TranslationsTest.php
-
message: "#^Offset 'target\\-language' does not exist on SimpleXMLElement\\|null\\.$#"
count: 1
path: TranslationsTest.php
- -
message: "#^Method App\\\\Tests\\\\Twig\\\\ContextTest\\:\\:getDefaultSettings\\(\\) return type has no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Tests\\\\Twig\\\\ContextTest\\:\\:getDefaultSettings\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1 count: 1

View File

@@ -687,6 +687,10 @@
<source>holiday</source> <source>holiday</source>
<target>Urlaub</target> <target>Urlaub</target>
</trans-unit> </trans-unit>
<trans-unit id="XE01Ar1" resname="parental">
<source>parental</source>
<target>Elternzeit</target>
</trans-unit>
<trans-unit id="MQKiG33" resname="profile.preferences"> <trans-unit id="MQKiG33" resname="profile.preferences">
<source>profile.preferences</source> <source>profile.preferences</source>
<target>Einstellungen</target> <target>Einstellungen</target>
@@ -1589,6 +1593,10 @@
<source>sickness</source> <source>sickness</source>
<target>Krankheit</target> <target>Krankheit</target>
</trans-unit> </trans-unit>
<trans-unit id="Nw2x5hI" resname="sickness_child">
<source>sickness_child</source>
<target>Krankheit eines Kindes</target>
</trans-unit>
<trans-unit id="2SmKENG" resname="other"> <trans-unit id="2SmKENG" resname="other">
<source>other</source> <source>other</source>
<target>Andere</target> <target>Andere</target>

View File

@@ -687,6 +687,10 @@
<source>holiday</source> <source>holiday</source>
<target>Holiday</target> <target>Holiday</target>
</trans-unit> </trans-unit>
<trans-unit id="XE01Ar1" resname="parental">
<source>parental</source>
<target>Parental leave</target>
</trans-unit>
<trans-unit id="MQKiG33" resname="profile.preferences"> <trans-unit id="MQKiG33" resname="profile.preferences">
<source>profile.preferences</source> <source>profile.preferences</source>
<target>Preferences</target> <target>Preferences</target>
@@ -1589,6 +1593,10 @@
<source>sickness</source> <source>sickness</source>
<target>Sickness</target> <target>Sickness</target>
</trans-unit> </trans-unit>
<trans-unit id="Nw2x5hI" resname="sickness_child">
<source>sickness_child</source>
<target>Sickness of a child</target>
</trans-unit>
<trans-unit id="2SmKENG" resname="other"> <trans-unit id="2SmKENG" resname="other">
<source>other</source> <source>other</source>
<target>Other</target> <target>Other</target>

View File

@@ -136,7 +136,7 @@
</trans-unit> </trans-unit>
<trans-unit id="ByeEMxW" resname="You do not have enough overtime, remaining are {{ value }}."> <trans-unit id="ByeEMxW" resname="You do not have enough overtime, remaining are {{ value }}.">
<source>You do not have enough overtime, remaining are {{ value }}.</source> <source>You do not have enough overtime, remaining are {{ value }}.</source>
<target>Sie haben nicht genug Überstunden, es verbleiben {{ Wert }}.</target> <target>Sie haben nicht genug Überstunden, es verbleiben {{ value }}.</target>
</trans-unit> </trans-unit>
<trans-unit id="DiaaRa1" resname="You cannot have two absences of this type at one day."> <trans-unit id="DiaaRa1" resname="You cannot have two absences of this type at one day.">
<source>You cannot have two absences of this type at one day.</source> <source>You cannot have two absences of this type at one day.</source>
@@ -148,7 +148,7 @@
</trans-unit> </trans-unit>
<trans-unit id="c2B6.lW" resname="You can book a maximum of {{ value }} days at once."> <trans-unit id="c2B6.lW" resname="You can book a maximum of {{ value }} days at once.">
<source>You can book a maximum of {{ value }} days at once.</source> <source>You can book a maximum of {{ value }} days at once.</source>
<target>Sie können maximal {{ Wert }} Tage auf einmal buchen.</target> <target>Sie können maximal {{ value }} Tage auf einmal buchen.</target>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>