Release 2.45 (#5721)

Co-authored-by: Henning Klein <info@henningklein.de>
This commit is contained in:
Kevin Papst
2025-12-21 16:42:34 +01:00
committed by GitHub
parent 8c1ed68817
commit afb5a0bba4
67 changed files with 905 additions and 254 deletions

View File

@@ -4,6 +4,10 @@ on:
push: push:
branches: branches:
- main - main
permissions:
pull-requests: read
jobs: jobs:
lockfiles: lockfiles:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -13,5 +17,5 @@ jobs:
uses: xalvarez/prevent-file-change-action@v3 uses: xalvarez/prevent-file-change-action@v3
with: with:
githubToken: ${{ secrets.GITHUB_TOKEN }} githubToken: ${{ secrets.GITHUB_TOKEN }}
pattern: .*\.lock$ pattern: .*\.lock$|^\.github\/.*$
trustedAuthors: kevinpapst, dependabot trustedAuthors: kevinpapst, dependabot

View File

@@ -143,7 +143,7 @@ $fixer
'whitespace_after_comma_in_array' => true, 'whitespace_after_comma_in_array' => true,
'yoda_style' => false, 'yoda_style' => false,
'ternary_to_null_coalescing' => true, 'ternary_to_null_coalescing' => true,
'visibility_required' => ['elements' => [ 'modifier_keywords' => ['elements' => [
'const', 'const',
'method', 'method',
'property', 'property',

View File

@@ -24,7 +24,7 @@ export default class KimaiAutocompleteTags extends KimaiAutocomplete {
API.get(apiUrl, {'name': query}, (data) => { API.get(apiUrl, {'name': query}, (data) => {
let results = []; let results = [];
for (let item of data) { for (let item of data) {
results.push({text: item.name, value: item.name, color: item['color-safe']}); results.push({text: item.name, value: item.name, color: item.color});
} }
callback(results); callback(results);
}, () => { }, () => {

View File

@@ -29,7 +29,7 @@ nelmio_api_doc:
- { alias: TimesheetEntity, type: App\Entity\Timesheet, groups: [Default, Entity, Timesheet, Timesheet_Entity, Not_Expanded] } - { alias: TimesheetEntity, type: App\Entity\Timesheet, groups: [Default, Entity, Timesheet, Timesheet_Entity, Not_Expanded] }
- { alias: TimesheetExpanded, type: App\Entity\Timesheet, groups: [Default, Entity, Timesheet, Timesheet_Entity, Expanded] } - { alias: TimesheetExpanded, type: App\Entity\Timesheet, groups: [Default, Entity, Timesheet, Timesheet_Entity, Expanded] }
- { alias: TimesheetCollection, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet, Not_Expanded] } - { alias: TimesheetCollection, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet, Not_Expanded] }
- { alias: TimesheetCollectionExpanded, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet, Subresource, Expanded] } - { alias: TimesheetCollectionExpanded, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet, Expanded] }
- { alias: UserCreateForm, type: App\Form\API\UserApiCreateForm, groups: [Default, Entity, User, User_Entity] } - { alias: UserCreateForm, type: App\Form\API\UserApiCreateForm, groups: [Default, Entity, User, User_Entity] }
- { alias: UserEditForm, type: App\Form\API\UserApiEditForm, groups: [Default, Entity, User, User_Entity] } - { alias: UserEditForm, type: App\Form\API\UserApiEditForm, groups: [Default, Entity, User, User_Entity] }
- { alias: User, type: App\Entity\User, groups: [Default] } - { alias: User, type: App\Entity\User, groups: [Default] }

View File

@@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* @version 2.45
*/
final class Version20251214160001 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add index for a query called on every timesheet page';
}
public function up(Schema $schema): void
{
$table = $schema->getTable('kimai2_tags');
if (!$table->hasIndex('IDX_27CAF54C7AB0E859')) {
// used to count the tags for the dropdown (filter and timesheet edit)
$table->addIndex(['visible'], 'IDX_27CAF54C7AB0E859');
}
}
public function down(Schema $schema): void
{
$table = $schema->getTable('kimai2_tags');
if ($table->hasIndex('IDX_27CAF54C7AB0E859')) {
$table->dropIndex('IDX_27CAF54C7AB0E859');
}
}
public function isTransactional(): bool
{
return false;
}
}

View File

@@ -649,16 +649,6 @@ parameters:
count: 1 count: 1
path: src/Controller/ActivityController.php path: src/Controller/ActivityController.php
-
message: "#^Parameter \\#1 \\$name of class App\\\\Entity\\\\Team constructor expects string, string\\|null given\\.$#"
count: 1
path: src/Controller/ActivityController.php
-
message: "#^Parameter \\#1 \\$name of class App\\\\Entity\\\\Team constructor expects string, string\\|null given\\.$#"
count: 1
path: src/Controller/CustomerController.php
- -
message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#" message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
count: 2 count: 2
@@ -779,11 +769,6 @@ parameters:
count: 1 count: 1
path: src/Controller/ProjectController.php path: src/Controller/ProjectController.php
-
message: "#^Parameter \\#1 \\$name of class App\\\\Entity\\\\Team constructor expects string, string\\|null given\\.$#"
count: 1
path: src/Controller/ProjectController.php
- -
message: "#^Cannot call method format\\(\\) on DateTime\\|null\\.$#" message: "#^Cannot call method format\\(\\) on DateTime\\|null\\.$#"
count: 1 count: 1

File diff suppressed because one or more lines are too long

View File

@@ -86,7 +86,7 @@ final class ActivityController extends BaseApiController
} }
$visible = $paramFetcher->get('visible'); $visible = $paramFetcher->get('visible');
if (\is_string($visible) && $visible !== '') { if (is_numeric($visible)) {
$query->setVisibility((int) $visible); $query->setVisibility((int) $visible);
} }

View File

@@ -17,7 +17,6 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormTypeInterface; use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
abstract class BaseApiController extends AbstractController abstract class BaseApiController extends AbstractController
{ {
@@ -85,8 +84,11 @@ abstract class BaseApiController extends AbstractController
$size = $all['size']; $size = $all['size'];
if (is_numeric($size)) { if (is_numeric($size)) {
$size = (int) $size; $size = (int) $size;
if ($size < 1 || $size > self::MAX_PAGE_SIZE) { if ($size < 1) {
throw new BadRequestHttpException('Size must be between 1 and ' . self::MAX_PAGE_SIZE); $size = BaseQuery::DEFAULT_PAGESIZE;
}
if ($size > self::MAX_PAGE_SIZE) {
$size = self::MAX_PAGE_SIZE;
} }
$query->setPageSize($size); $query->setPageSize($size);
} }

View File

@@ -76,7 +76,7 @@ final class CustomerController extends BaseApiController
} }
$visible = $paramFetcher->get('visible'); $visible = $paramFetcher->get('visible');
if (\is_string($visible) && $visible !== '') { if (is_numeric($visible)) {
$query->setVisibility((int) $visible); $query->setVisibility((int) $visible);
} }

View File

@@ -99,7 +99,7 @@ final class ProjectController extends BaseApiController
} }
$visible = $paramFetcher->get('visible'); $visible = $paramFetcher->get('visible');
if (\is_string($visible) && $visible !== '') { if (is_numeric($visible)) {
$query->setVisibility((int) $visible); $query->setVisibility((int) $visible);
} }

View File

@@ -20,6 +20,7 @@ use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\Query\TeamQuery; use App\Repository\Query\TeamQuery;
use App\Repository\TeamRepository; use App\Repository\TeamRepository;
use App\User\TeamService;
use FOS\RestBundle\View\View; use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
@@ -41,7 +42,8 @@ final class TeamController extends BaseApiController
public function __construct( public function __construct(
private readonly ViewHandlerInterface $viewHandler, private readonly ViewHandlerInterface $viewHandler,
private readonly TeamRepository $repository private readonly TeamRepository $repository,
private readonly TeamService $teamService
) )
{ {
} }
@@ -88,7 +90,7 @@ final class TeamController extends BaseApiController
#[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_team', requirements: ['id' => '\d+'])] #[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_team', requirements: ['id' => '\d+'])]
public function deleteAction(Team $team): Response public function deleteAction(Team $team): Response
{ {
$this->repository->deleteTeam($team); $this->teamService->deleteTeam($team);
$view = new View(null, Response::HTTP_NO_CONTENT); $view = new View(null, Response::HTTP_NO_CONTENT);
@@ -104,13 +106,13 @@ final class TeamController extends BaseApiController
#[Route(methods: ['POST'], path: '', name: 'post_team')] #[Route(methods: ['POST'], path: '', name: 'post_team')]
public function postAction(Request $request): Response public function postAction(Request $request): Response
{ {
$team = new Team(''); $team = $this->teamService->createNewTeam('');
$form = $this->createForm(TeamApiEditForm::class, $team); $form = $this->createForm(TeamApiEditForm::class, $team);
$form->submit($request->request->all()); $form->submit($request->request->all());
if ($form->isValid()) { if ($form->isValid()) {
$this->repository->saveTeam($team); $this->teamService->saveTeam($team);
$view = new View($team, 200); $view = new View($team, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY); $view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -139,6 +141,8 @@ final class TeamController extends BaseApiController
$team->removeMember($member); $team->removeMember($member);
$this->repository->removeTeamMember($member); $this->repository->removeTeamMember($member);
} }
// this fails, if we use the teamservice, because the validator
// complains about teams without members or teamleads
$this->repository->saveTeam($team); $this->repository->saveTeam($team);
} }
@@ -154,7 +158,7 @@ final class TeamController extends BaseApiController
return $this->viewHandler->handle($view); return $this->viewHandler->handle($view);
} }
$this->repository->saveTeam($team); $this->teamService->saveTeam($team);
$view = new View($team, Response::HTTP_OK); $view = new View($team, Response::HTTP_OK);
$view->getContext()->setGroups(self::GROUPS_ENTITY); $view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -178,7 +182,7 @@ final class TeamController extends BaseApiController
$team->addUser($member); $team->addUser($member);
$this->repository->saveTeam($team); $this->teamService->saveTeam($team);
$view = new View($team, Response::HTTP_OK); $view = new View($team, Response::HTTP_OK);
$view->getContext()->setGroups(self::GROUPS_ENTITY); $view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -206,7 +210,7 @@ final class TeamController extends BaseApiController
$team->removeUser($member); $team->removeUser($member);
$this->repository->saveTeam($team); $this->teamService->saveTeam($team);
$view = new View($team, Response::HTTP_OK); $view = new View($team, Response::HTTP_OK);
$view->getContext()->setGroups(self::GROUPS_ENTITY); $view->getContext()->setGroups(self::GROUPS_ENTITY);

View File

@@ -66,7 +66,7 @@ final class UserController extends BaseApiController
$query->setCurrentUser($this->getUser()); $query->setCurrentUser($this->getUser());
$visible = $paramFetcher->get('visible'); $visible = $paramFetcher->get('visible');
if (\is_string($visible) && $visible !== '') { if (is_numeric($visible)) {
$query->setVisibility((int) $visible); $query->setVisibility((int) $visible);
} }

View File

@@ -17,11 +17,11 @@ final class Constants
/** /**
* The current release version * The current release version
*/ */
public const VERSION = '2.44.0'; public const VERSION = '2.45.0';
/** /**
* The current release: major * 10000 + minor * 100 + patch * The current release: major * 10000 + minor * 100 + patch
*/ */
public const VERSION_ID = 24400; public const VERSION_ID = 24500;
/** /**
* The software name * The software name
*/ */

View File

@@ -15,7 +15,6 @@ use App\Configuration\SystemConfiguration;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\ActivityRate; use App\Entity\ActivityRate;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\Team;
use App\Event\ActivityDetailControllerEvent; use App\Event\ActivityDetailControllerEvent;
use App\Event\ActivityMetaDisplayEvent; use App\Event\ActivityMetaDisplayEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter; use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
@@ -32,6 +31,7 @@ use App\Repository\Query\ActivityQuery;
use App\Repository\Query\TeamQuery; use App\Repository\Query\TeamQuery;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use App\Repository\TeamRepository; use App\Repository\TeamRepository;
use App\User\TeamService;
use App\Utils\DataTable; use App\Utils\DataTable;
use App\Utils\PageSetup; use App\Utils\PageSetup;
use Exception; use Exception;
@@ -40,6 +40,7 @@ use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -303,19 +304,24 @@ final class ActivityController extends AbstractController
#[Route(path: '/{id}/create_team', name: 'activity_team_create', methods: ['GET'])] #[Route(path: '/{id}/create_team', name: 'activity_team_create', methods: ['GET'])]
#[IsGranted('create_team')] #[IsGranted('create_team')]
#[IsGranted('permissions', 'activity')] #[IsGranted('permissions', 'activity')]
public function createDefaultTeamAction(Activity $activity, TeamRepository $teamRepository): Response public function createDefaultTeamAction(Activity $activity, TeamService $teamService): Response
{ {
$defaultTeam = $teamRepository->findOneBy(['name' => $activity->getName()]); $name = $activity->getName();
if ($name === null) {
throw new BadRequestHttpException('Cannot create default team for activity with empty name: ' . $activity->getId());
}
$defaultTeam = $teamService->findTeamByName($name);
if (null === $defaultTeam) { if (null === $defaultTeam) {
$defaultTeam = new Team($activity->getName()); $defaultTeam = $teamService->createNewTeam($name);
} }
$defaultTeam->addTeamlead($this->getUser()); $defaultTeam->addTeamlead($this->getUser());
$defaultTeam->addActivity($activity); $defaultTeam->addActivity($activity);
try { try {
$teamRepository->saveTeam($defaultTeam); $teamService->saveTeam($defaultTeam);
} catch (Exception $ex) { } catch (Exception $ex) {
$this->flashUpdateException($ex); $this->flashUpdateException($ex);
} }

View File

@@ -14,7 +14,6 @@ use App\Customer\CustomerStatisticService;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\CustomerComment; use App\Entity\CustomerComment;
use App\Entity\CustomerRate; use App\Entity\CustomerRate;
use App\Entity\Team;
use App\Event\CustomerDetailControllerEvent; use App\Event\CustomerDetailControllerEvent;
use App\Event\CustomerMetaDisplayEvent; use App\Event\CustomerMetaDisplayEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter; use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
@@ -35,6 +34,7 @@ use App\Repository\Query\TeamQuery;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use App\Repository\Query\VisibilityInterface; use App\Repository\Query\VisibilityInterface;
use App\Repository\TeamRepository; use App\Repository\TeamRepository;
use App\User\TeamService;
use App\Utils\DataTable; use App\Utils\DataTable;
use App\Utils\PageSetup; use App\Utils\PageSetup;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
@@ -42,6 +42,7 @@ use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfToken; use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
@@ -239,19 +240,24 @@ final class CustomerController extends AbstractController
#[Route(path: '/{id}/create_team', name: 'customer_team_create', methods: ['GET'])] #[Route(path: '/{id}/create_team', name: 'customer_team_create', methods: ['GET'])]
#[IsGranted('create_team')] #[IsGranted('create_team')]
#[IsGranted('permissions', 'customer')] #[IsGranted('permissions', 'customer')]
public function createDefaultTeamAction(Customer $customer, TeamRepository $teamRepository): Response public function createDefaultTeamAction(Customer $customer, TeamService $teamService): Response
{ {
$defaultTeam = $teamRepository->findOneBy(['name' => $customer->getName()]); $name = $customer->getName();
if ($name === null) {
throw new BadRequestHttpException('Cannot create default team for customer with empty name: ' . $customer->getId());
}
$defaultTeam = $teamService->findTeamByName($name);
if (null === $defaultTeam) { if (null === $defaultTeam) {
$defaultTeam = new Team($customer->getName()); $defaultTeam = $teamService->createNewTeam($name);
} }
$defaultTeam->addTeamlead($this->getUser()); $defaultTeam->addTeamlead($this->getUser());
$defaultTeam->addCustomer($customer); $defaultTeam->addCustomer($customer);
try { try {
$teamRepository->saveTeam($defaultTeam); $teamService->saveTeam($defaultTeam);
} catch (\Exception $ex) { } catch (\Exception $ex) {
$this->flashUpdateException($ex); $this->flashUpdateException($ex);
} }

View File

@@ -14,7 +14,6 @@ use App\Entity\Customer;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\ProjectComment; use App\Entity\ProjectComment;
use App\Entity\ProjectRate; use App\Entity\ProjectRate;
use App\Entity\Team;
use App\Event\ProjectDetailControllerEvent; use App\Event\ProjectDetailControllerEvent;
use App\Event\ProjectMetaDisplayEvent; use App\Event\ProjectMetaDisplayEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter; use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
@@ -38,6 +37,7 @@ use App\Repository\Query\TeamQuery;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use App\Repository\Query\VisibilityInterface; use App\Repository\Query\VisibilityInterface;
use App\Repository\TeamRepository; use App\Repository\TeamRepository;
use App\User\TeamService;
use App\Utils\Context; use App\Utils\Context;
use App\Utils\DataTable; use App\Utils\DataTable;
use App\Utils\PageSetup; use App\Utils\PageSetup;
@@ -46,6 +46,7 @@ use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfToken; use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
@@ -267,19 +268,24 @@ final class ProjectController extends AbstractController
#[Route(path: '/{id}/create_team', name: 'project_team_create', methods: ['GET'])] #[Route(path: '/{id}/create_team', name: 'project_team_create', methods: ['GET'])]
#[IsGranted('create_team')] #[IsGranted('create_team')]
#[IsGranted('permissions', 'project')] #[IsGranted('permissions', 'project')]
public function createDefaultTeamAction(Project $project, TeamRepository $teamRepository): Response public function createDefaultTeamAction(Project $project, TeamService $teamService): Response
{ {
$defaultTeam = $teamRepository->findOneBy(['name' => $project->getName()]); $name = $project->getName();
if ($name === null) {
throw new BadRequestHttpException('Cannot create default team for project with empty name: ' . $project->getId());
}
$defaultTeam = $teamService->findTeamByName($name);
if (null === $defaultTeam) { if (null === $defaultTeam) {
$defaultTeam = new Team($project->getName()); $defaultTeam = $teamService->createNewTeam($name);
} }
$defaultTeam->addTeamlead($this->getUser()); $defaultTeam->addTeamlead($this->getUser());
$defaultTeam->addProject($project); $defaultTeam->addProject($project);
try { try {
$teamRepository->saveTeam($defaultTeam); $teamService->saveTeam($defaultTeam);
} catch (\Exception $ex) { } catch (\Exception $ex) {
$this->flashUpdateException($ex); $this->flashUpdateException($ex);
} }

View File

@@ -16,6 +16,7 @@ use App\Form\Type\CustomerType;
use App\Form\Type\ProjectType; use App\Form\Type\ProjectType;
use App\Repository\Query\TeamQuery; use App\Repository\Query\TeamQuery;
use App\Repository\TeamRepository; use App\Repository\TeamRepository;
use App\User\TeamService;
use App\Utils\DataTable; use App\Utils\DataTable;
use App\Utils\PageSetup; use App\Utils\PageSetup;
use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\Extension\Core\Type\FormType;
@@ -30,7 +31,10 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('view_team')] #[IsGranted('view_team')]
final class TeamController extends AbstractController final class TeamController extends AbstractController
{ {
public function __construct(private TeamRepository $repository) public function __construct(
private readonly TeamRepository $repository,
private readonly TeamService $teamService,
)
{ {
} }
@@ -85,7 +89,9 @@ final class TeamController extends AbstractController
#[IsGranted('create_team')] #[IsGranted('create_team')]
public function createTeam(Request $request): Response public function createTeam(Request $request): Response
{ {
return $this->renderEditScreen(new Team(''), $request, true); $team = $this->teamService->createNewTeam('');
return $this->renderEditScreen($team, $request, true);
} }
#[Route(path: '/{id}/duplicate', name: 'team_duplicate', methods: ['GET', 'POST'])] #[Route(path: '/{id}/duplicate', name: 'team_duplicate', methods: ['GET', 'POST'])]
@@ -128,7 +134,7 @@ final class TeamController extends AbstractController
if ($editForm->isSubmitted() && $editForm->isValid()) { if ($editForm->isSubmitted() && $editForm->isValid()) {
try { try {
$this->repository->saveTeam($team); $this->teamService->saveTeam($team);
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]); return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
@@ -167,7 +173,7 @@ final class TeamController extends AbstractController
if ($editForm->isSubmitted() && $editForm->isValid()) { if ($editForm->isSubmitted() && $editForm->isValid()) {
try { try {
$this->repository->saveTeam($team); $this->teamService->saveTeam($team);
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
if ($create) { if ($create) {

View File

@@ -53,7 +53,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[ORM\ManyToOne(targetEntity: Project::class)] #[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')] #[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Subresource', 'Expanded'])] #[Serializer\Groups(['Expanded'])]
#[OA\Property(ref: '#/components/schemas/ProjectExpanded')] #[OA\Property(ref: '#/components/schemas/ProjectExpanded')]
private ?Project $project = null; private ?Project $project = null;
/** /**
@@ -96,7 +96,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
*/ */
#[ORM\OneToMany(mappedBy: 'activity', targetEntity: ActivityMeta::class, cascade: ['persist'])] #[ORM\OneToMany(mappedBy: 'activity', targetEntity: ActivityMeta::class, cascade: ['persist'])]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Activity'])] #[Serializer\Groups(['Default'])]
#[Serializer\Type(name: 'array<App\Entity\ActivityMeta>')] #[Serializer\Type(name: 'array<App\Entity\ActivityMeta>')]
#[Serializer\SerializedName('metaFields')] #[Serializer\SerializedName('metaFields')]
#[Serializer\Accessor(getter: 'getVisibleMetaFields')] #[Serializer\Accessor(getter: 'getVisibleMetaFields')]

View File

@@ -18,7 +18,7 @@ use Symfony\Component\Validator\Constraints as Assert;
trait BudgetTrait trait BudgetTrait
{ {
/** /**
* The total monetary budget, will be zero if not configured. * The total monetary budget (default: 0).
*/ */
#[ORM\Column(name: 'budget', type: Types::FLOAT, nullable: false)] #[ORM\Column(name: 'budget', type: Types::FLOAT, nullable: false)]
#[Assert\Range(min: 0.00, max: 900000000000.00)] #[Assert\Range(min: 0.00, max: 900000000000.00)]
@@ -28,7 +28,7 @@ trait BudgetTrait
#[Exporter\Expose(label: 'budget', type: 'float')] #[Exporter\Expose(label: 'budget', type: 'float')]
private float $budget = 0.00; private float $budget = 0.00;
/** /**
* The time budget in seconds, will be zero if not configured. * The time budget in seconds (default: 0).
*/ */
#[ORM\Column(name: 'time_budget', type: Types::INTEGER, nullable: false)] #[ORM\Column(name: 'time_budget', type: Types::INTEGER, nullable: false)]
#[Assert\Range(min: 0, max: 2145600000)] #[Assert\Range(min: 0, max: 2145600000)]
@@ -39,8 +39,8 @@ trait BudgetTrait
private int $timeBudget = 0; private int $timeBudget = 0;
/** /**
* The type of budget: * The type of budget:
* - null = default / full time * - null = default / full time
* - month = monthly budget * - month = monthly budget
*/ */
#[ORM\Column(name: 'budget_type', type: Types::STRING, length: 10, nullable: true)] #[ORM\Column(name: 'budget_type', type: Types::STRING, length: 10, nullable: true)]
#[Serializer\Expose] #[Serializer\Expose]

View File

@@ -11,6 +11,7 @@ namespace App\Entity;
use App\Constants; use App\Constants;
use App\Export\Annotation as Exporter; use App\Export\Annotation as Exporter;
use App\Utils\Color;
use App\Validator\Constraints as Constraints; use App\Validator\Constraints as Constraints;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
@@ -22,8 +23,7 @@ trait ColorTrait
* The assigned color in HTML hex format, e.g. #dd1d00 * The assigned color in HTML hex format, e.g. #dd1d00
*/ */
#[ORM\Column(name: 'color', type: Types::STRING, length: 7, nullable: true)] #[ORM\Column(name: 'color', type: Types::STRING, length: 7, nullable: true)]
#[Serializer\Expose] #[Serializer\Exclude]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'color')] #[Exporter\Expose(label: 'color')]
#[Constraints\HexColor] #[Constraints\HexColor]
private ?string $color = null; private ?string $color = null;
@@ -46,4 +46,17 @@ trait ColorTrait
{ {
$this->color = $color; $this->color = $color;
} }
abstract public function getName(): ?string;
/**
* Internal value: this color will never be empty and is generated by the tag name if not set explicit.
*/
#[Serializer\VirtualProperty]
#[Serializer\SerializedName('color')]
#[Serializer\Groups(['Default'])]
public function getColorSafe(): string
{
return $this->getColor() ?? (new Color())->getRandom($this->getName());
}
} }

View File

@@ -122,19 +122,19 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[ORM\Column(name: 'phone', type: Types::STRING, length: 30, nullable: true)] #[ORM\Column(name: 'phone', type: Types::STRING, length: 30, nullable: true)]
#[Assert\Length(max: 30)] #[Assert\Length(max: 30)]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Customer'])] #[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'phone')] #[Exporter\Expose(label: 'phone')]
private ?string $phone = null; private ?string $phone = null;
#[ORM\Column(name: 'fax', type: Types::STRING, length: 30, nullable: true)] #[ORM\Column(name: 'fax', type: Types::STRING, length: 30, nullable: true)]
#[Assert\Length(max: 30)] #[Assert\Length(max: 30)]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Customer'])] #[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'fax')] #[Exporter\Expose(label: 'fax')]
private ?string $fax = null; private ?string $fax = null;
#[ORM\Column(name: 'mobile', type: Types::STRING, length: 30, nullable: true)] #[ORM\Column(name: 'mobile', type: Types::STRING, length: 30, nullable: true)]
#[Assert\Length(max: 30)] #[Assert\Length(max: 30)]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Customer'])] #[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'mobile')] #[Exporter\Expose(label: 'mobile')]
private ?string $mobile = null; private ?string $mobile = null;
/** /**
@@ -149,7 +149,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[ORM\Column(name: 'homepage', type: Types::STRING, length: 100, nullable: true)] #[ORM\Column(name: 'homepage', type: Types::STRING, length: 100, nullable: true)]
#[Assert\Length(max: 100)] #[Assert\Length(max: 100)]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Customer'])] #[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'homepage')] #[Exporter\Expose(label: 'homepage')]
private ?string $homepage = null; private ?string $homepage = null;
/** /**
@@ -160,7 +160,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Assert\Timezone] #[Assert\Timezone]
#[Assert\Length(max: 64)] #[Assert\Length(max: 64)]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Customer'])] #[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'timezone')] #[Exporter\Expose(label: 'timezone')]
private ?string $timezone = null; private ?string $timezone = null;
/** /**
@@ -170,7 +170,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
*/ */
#[ORM\OneToMany(mappedBy: 'customer', targetEntity: CustomerMeta::class, cascade: ['persist'])] #[ORM\OneToMany(mappedBy: 'customer', targetEntity: CustomerMeta::class, cascade: ['persist'])]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Customer'])] #[Serializer\Groups(['Default'])]
#[Serializer\Type(name: 'array<App\Entity\CustomerMeta>')] #[Serializer\Type(name: 'array<App\Entity\CustomerMeta>')]
#[Serializer\SerializedName('metaFields')] #[Serializer\SerializedName('metaFields')]
#[Serializer\Accessor(getter: 'getVisibleMetaFields')] #[Serializer\Accessor(getter: 'getVisibleMetaFields')]

View File

@@ -60,7 +60,7 @@ class Invoice implements EntityWithMetaFields
private ?string $invoiceNumber = null; private ?string $invoiceNumber = null;
#[ORM\Column(name: 'comment', type: Types::TEXT, nullable: true)] #[ORM\Column(name: 'comment', type: Types::TEXT, nullable: true)]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Invoice'])] #[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'comment')] #[Exporter\Expose(label: 'comment')]
private ?string $comment = null; private ?string $comment = null;
#[ORM\ManyToOne(targetEntity: Customer::class)] #[ORM\ManyToOne(targetEntity: Customer::class)]
@@ -79,8 +79,6 @@ class Invoice implements EntityWithMetaFields
private ?User $user = null; private ?User $user = null;
#[ORM\Column(name: 'created_at', type: Types::DATETIME_MUTABLE, nullable: false)] #[ORM\Column(name: 'created_at', type: Types::DATETIME_MUTABLE, nullable: false)]
#[Assert\NotNull] #[Assert\NotNull]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private ?\DateTime $createdAt = null; private ?\DateTime $createdAt = null;
#[ORM\Column(name: 'timezone', type: Types::STRING, length: 64, nullable: false)] #[ORM\Column(name: 'timezone', type: Types::STRING, length: 64, nullable: false)]
private ?string $timezone = null; private ?string $timezone = null;
@@ -107,7 +105,7 @@ class Invoice implements EntityWithMetaFields
#[Assert\NotNull] #[Assert\NotNull]
#[Assert\Range(min: 0, max: 999)] #[Assert\Range(min: 0, max: 999)]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Invoice'])] #[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'due_days', type: 'integer')] #[Exporter\Expose(label: 'due_days', type: 'integer')]
private int $dueDays = 30; private int $dueDays = 30;
#[ORM\Column(name: 'vat', type: Types::FLOAT, nullable: false)] #[ORM\Column(name: 'vat', type: Types::FLOAT, nullable: false)]
@@ -140,7 +138,7 @@ class Invoice implements EntityWithMetaFields
*/ */
#[ORM\OneToMany(mappedBy: 'invoice', targetEntity: InvoiceMeta::class, cascade: ['persist'])] #[ORM\OneToMany(mappedBy: 'invoice', targetEntity: InvoiceMeta::class, cascade: ['persist'])]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Invoice'])] #[Serializer\Groups(['Default'])]
#[Serializer\Type(name: 'array<App\Entity\InvoiceMeta>')] #[Serializer\Type(name: 'array<App\Entity\InvoiceMeta>')]
#[Serializer\SerializedName('metaFields')] #[Serializer\SerializedName('metaFields')]
#[Serializer\Accessor(getter: 'getVisibleMetaFields')] #[Serializer\Accessor(getter: 'getVisibleMetaFields')]
@@ -176,6 +174,9 @@ class Invoice implements EntityWithMetaFields
return $this->total; return $this->total;
} }
#[Serializer\VirtualProperty]
#[Serializer\SerializedName('createdAt')]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(name: 'createdAt', label: 'date', type: 'datetime')] #[Exporter\Expose(name: 'createdAt', label: 'date', type: 'datetime')]
public function getCreatedAt(): ?\DateTime public function getCreatedAt(): ?\DateTime
{ {
@@ -202,6 +203,9 @@ class Invoice implements EntityWithMetaFields
return $dueDate; return $dueDate;
} }
#[Serializer\VirtualProperty()]
#[Serializer\SerializedName('overdue')]
#[Serializer\Groups(['Default'])]
public function isOverdue(): bool public function isOverdue(): bool
{ {
if (null === $this->getDueDate()) { if (null === $this->getDueDate()) {

View File

@@ -56,7 +56,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull] #[Assert\NotNull]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Subresource', 'Expanded'])] #[Serializer\Groups(['Expanded'])]
#[OA\Property(ref: '#/components/schemas/Customer')] #[OA\Property(ref: '#/components/schemas/Customer')]
private ?Customer $customer = null; private ?Customer $customer = null;
/** /**
@@ -85,7 +85,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
*/ */
#[ORM\Column(name: 'order_date', type: Types::DATETIME_MUTABLE, nullable: true)] #[ORM\Column(name: 'order_date', type: Types::DATETIME_MUTABLE, nullable: true)]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Project_Entity'])] #[Serializer\Groups(['Default'])]
#[Serializer\Type(name: "DateTime<'Y-m-d'>")] #[Serializer\Type(name: "DateTime<'Y-m-d'>")]
#[Serializer\Accessor(getter: 'getOrderDate')] #[Serializer\Accessor(getter: 'getOrderDate')]
private ?\DateTime $orderDate = null; private ?\DateTime $orderDate = null;
@@ -141,7 +141,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
*/ */
#[ORM\OneToMany(mappedBy: 'project', targetEntity: ProjectMeta::class, cascade: ['persist'])] #[ORM\OneToMany(mappedBy: 'project', targetEntity: ProjectMeta::class, cascade: ['persist'])]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Project'])] #[Serializer\Groups(['Default'])]
#[Serializer\Type(name: 'array<App\Entity\ProjectMeta>')] #[Serializer\Type(name: 'array<App\Entity\ProjectMeta>')]
#[Serializer\SerializedName('metaFields')] #[Serializer\SerializedName('metaFields')]
#[Serializer\Accessor(getter: 'getVisibleMetaFields')] #[Serializer\Accessor(getter: 'getVisibleMetaFields')]

View File

@@ -10,7 +10,6 @@
namespace App\Entity; namespace App\Entity;
use App\Repository\TagRepository; use App\Repository\TagRepository;
use App\Utils\Color;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer; use JMS\Serializer\Annotation as Serializer;
@@ -21,9 +20,9 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\UniqueConstraint(columns: ['name'])] #[ORM\UniqueConstraint(columns: ['name'])]
#[ORM\Entity(repositoryClass: TagRepository::class)] #[ORM\Entity(repositoryClass: TagRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')] #[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[ORM\Index(columns: ['visible'])]
#[UniqueEntity('name')] #[UniqueEntity('name')]
#[Serializer\ExclusionPolicy('all')] #[Serializer\ExclusionPolicy('all')]
#[Serializer\VirtualProperty('ColorSafe', exp: 'object.getColorSafe()', options: [new Serializer\SerializedName('color-safe'), new Serializer\Type(name: 'string'), new Serializer\Groups(['Default'])])]
class Tag class Tag
{ {
/** /**
@@ -88,9 +87,4 @@ class Tag
{ {
return $this->getName(); return $this->getName();
} }
public function getColorSafe(): string
{
return $this->getColor() ?? (new Color())->getRandom($this->getName());
}
} }

View File

@@ -54,23 +54,23 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
use ModifiedTrait; use ModifiedTrait;
/** /**
* Category: Normal work-time (default category) * @deprecated since 2.45
*/ */
public const WORK = 'work'; public const WORK = 'work';
/** /**
* Category: Holiday * @deprecated since 2.45
*/ */
public const HOLIDAY = 'holiday'; public const HOLIDAY = 'holiday';
/** /**
* Category: Sickness * @deprecated since 2.45
*/ */
public const SICKNESS = 'sickness'; public const SICKNESS = 'sickness';
/** /**
* Category: Parental leave * @deprecated since 2.45
*/ */
public const PARENTAL = 'parental'; public const PARENTAL = 'parental';
/** /**
* Category: Overtime reduction * @deprecated since 2.45
*/ */
public const OVERTIME = 'overtime'; public const OVERTIME = 'overtime';
@@ -132,27 +132,29 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Default'])] #[Serializer\Groups(['Default'])]
private ?int $duration = 0; private ?int $duration = 0;
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[ORM\Column(name: 'break', type: Types::INTEGER, nullable: true)] #[ORM\Column(name: 'break', type: Types::INTEGER, nullable: true)]
private ?int $break = 0; private ?int $break = 0;
#[ORM\ManyToOne(targetEntity: User::class)] #[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: '`user`', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] #[ORM\JoinColumn(name: '`user`', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull] #[Assert\NotNull]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Subresource', 'Expanded'])] #[Serializer\Groups(['Expanded'])]
#[OA\Property(ref: '#/components/schemas/User')] #[OA\Property(ref: '#/components/schemas/User')]
private ?User $user = null; private ?User $user = null;
#[ORM\ManyToOne(targetEntity: Activity::class)] #[ORM\ManyToOne(targetEntity: Activity::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull] #[Assert\NotNull]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Subresource', 'Expanded'])] #[Serializer\Groups(['Expanded'])]
#[OA\Property(ref: '#/components/schemas/ActivityExpanded')] #[OA\Property(ref: '#/components/schemas/ActivityExpanded')]
private ?Activity $activity = null; private ?Activity $activity = null;
#[ORM\ManyToOne(targetEntity: Project::class)] #[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull] #[Assert\NotNull]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Subresource', 'Expanded'])] #[Serializer\Groups(['Expanded'])]
#[OA\Property(ref: '#/components/schemas/ProjectExpanded')] #[OA\Property(ref: '#/components/schemas/ProjectExpanded')]
private ?Project $project = null; private ?Project $project = null;
#[ORM\Column(name: 'description', type: Types::TEXT, nullable: true)] #[ORM\Column(name: 'description', type: Types::TEXT, nullable: true)]
@@ -196,7 +198,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
private ?string $billableMode = self::BILLABLE_DEFAULT; private ?string $billableMode = self::BILLABLE_DEFAULT;
#[ORM\Column(name: 'category', type: Types::STRING, length: 10, nullable: false, options: ['default' => 'work'])] #[ORM\Column(name: 'category', type: Types::STRING, length: 10, nullable: false, options: ['default' => 'work'])]
#[Assert\NotNull] #[Assert\NotNull]
private ?string $category = self::WORK; private ?string $category = 'work';
/** /**
* Tags * Tags
* *
@@ -503,11 +505,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
public function setCategory(string $category): Timesheet public function setCategory(string $category): Timesheet
{ {
$allowed = [self::WORK, self::HOLIDAY, self::SICKNESS, self::PARENTAL, self::OVERTIME]; @trigger_error('Timesheet::setCategory() is deprecated.', E_USER_DEPRECATED);
if (!\in_array($category, $allowed)) {
throw new \InvalidArgumentException(\sprintf('Invalid timesheet category "%s" given, expected one of: %s', $category, implode(', ', $allowed)));
}
$this->category = $category; $this->category = $category;

View File

@@ -169,6 +169,8 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
#[Assert\NotBlank(groups: ['Registration', 'UserCreate', 'Profile'])] #[Assert\NotBlank(groups: ['Registration', 'UserCreate', 'Profile'])]
#[Assert\Length(min: 2, max: 180)] #[Assert\Length(min: 2, max: 180)]
#[Assert\Email(mode: 'html5', groups: ['Registration', 'UserCreate', 'Profile'])] #[Assert\Email(mode: 'html5', groups: ['Registration', 'UserCreate', 'Profile'])]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private ?string $email = null; private ?string $email = null;
#[ORM\Column(name: 'account', type: Types::STRING, length: 30, nullable: true)] #[ORM\Column(name: 'account', type: Types::STRING, length: 30, nullable: true)]
#[Assert\Length(max: 30)] #[Assert\Length(max: 30)]
@@ -219,6 +221,8 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
#[ORM\Column(name: 'totp_enabled', type: Types::BOOLEAN, nullable: false, options: ['default' => false])] #[ORM\Column(name: 'totp_enabled', type: Types::BOOLEAN, nullable: false, options: ['default' => false])]
private bool $totpEnabled = false; private bool $totpEnabled = false;
#[ORM\Column(name: 'system_account', type: Types::BOOLEAN, nullable: false, options: ['default' => false])] #[ORM\Column(name: 'system_account', type: Types::BOOLEAN, nullable: false, options: ['default' => false])]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private bool $systemAccount = false; private bool $systemAccount = false;
#[ORM\ManyToOne(targetEntity: User::class)] #[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
@@ -352,6 +356,9 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
$all = []; $all = [];
foreach ($this->preferences as $preference) { foreach ($this->preferences as $preference) {
if ($preference->getName() === null || $preference->getName()[0] === '_') {
continue;
}
if ($preference->isEnabled() && !\in_array($preference->getName(), $skip)) { if ($preference->isEnabled() && !\in_array($preference->getName(), $skip)) {
$all[] = $preference; $all[] = $preference;
} }
@@ -370,7 +377,6 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
/** /**
* @param iterable<UserPreference> $preferences * @param iterable<UserPreference> $preferences
* @return User
*/ */
public function setPreferences(iterable $preferences): User public function setPreferences(iterable $preferences): User
{ {
@@ -384,7 +390,6 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
} }
/** /**
* @param string $name
* @param bool|int|string|float|null $value * @param bool|int|string|float|null $value
*/ */
public function setPreferenceValue(string $name, $value = null): void public function setPreferenceValue(string $name, $value = null): void
@@ -419,7 +424,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
*/ */
#[Serializer\VirtualProperty] #[Serializer\VirtualProperty]
#[Serializer\SerializedName('locale')] #[Serializer\SerializedName('locale')]
#[Serializer\Groups(['User_Entity'])] #[Serializer\Groups(['Default'])]
#[OA\Property(type: 'string')] #[OA\Property(type: 'string')]
public function getLocale(): string public function getLocale(): string
{ {
@@ -434,7 +439,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
#[Serializer\VirtualProperty] #[Serializer\VirtualProperty]
#[Serializer\SerializedName('timezone')] #[Serializer\SerializedName('timezone')]
#[Serializer\Groups(['User_Entity'])] #[Serializer\Groups(['Default'])]
#[OA\Property(type: 'string')] #[OA\Property(type: 'string')]
public function getTimezone(): string public function getTimezone(): string
{ {
@@ -442,11 +447,11 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
} }
/** /**
* The locale used for translations * The locale used for translating the UI
*/ */
#[Serializer\VirtualProperty] #[Serializer\VirtualProperty]
#[Serializer\SerializedName('language')] #[Serializer\SerializedName('language')]
#[Serializer\Groups(['User_Entity'])] #[Serializer\Groups(['Default'])]
#[OA\Property(type: 'string')] #[OA\Property(type: 'string')]
public function getLanguage(): string public function getLanguage(): string
{ {

View File

@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Entity\Team;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Base event class triggered for Team manipulations.
*/
abstract class AbstractTeamEvent extends Event
{
public function __construct(private readonly Team $team)
{
}
public function getTeam(): Team
{
return $this->team;
}
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
/**
* Triggered for new team instances, which might or might not be saved.
*/
final class TeamCreateEvent extends AbstractTeamEvent
{
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'team.created', description: 'Triggered after a team was created', payload: 'object.getTeam()')]
final class TeamCreatePostEvent extends AbstractTeamEvent
{
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
/**
* Triggered for team instances, which are just about to being saved.
*/
final class TeamCreatePreEvent extends AbstractTeamEvent
{
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'team.deleted', description: 'Triggered after a team was deleted', payload: 'object.getTeam()')]
final class TeamDeleteEvent extends AbstractTeamEvent
{
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'team.updated', description: 'Triggered after a team was updated', payload: 'object.getTeam()')]
final class TeamUpdatePostEvent extends AbstractTeamEvent
{
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
/**
* Triggered for team instances, which are just about to being updated.
*/
final class TeamUpdatePreEvent extends AbstractTeamEvent
{
}

View File

@@ -29,6 +29,8 @@ class ThemeEvent extends Event
private string $content = ''; private string $content = '';
/** /**
* User is nullable, because theme events are triggered on anonymous pages, like "Login" or "Kiosk"
*
* @param array<string, mixed|array<mixed>> $payload * @param array<string, mixed|array<mixed>> $payload
*/ */
public function __construct(private readonly ?User $user = null, protected array $payload = []) public function __construct(private readonly ?User $user = null, protected array $payload = [])

View File

@@ -17,6 +17,8 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/** /**
* Adds the links in the user profile dropdown in the template on each page.
*
* @internal * @internal
*/ */
class UserDetailsSubscriber implements EventSubscriberInterface class UserDetailsSubscriber implements EventSubscriberInterface

View File

@@ -105,6 +105,10 @@ 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);
} }
if ($this->systemConfiguration->isBreakTimeEnabled()) {
$builder->add('break', DurationType::class, ['label' => 'break', 'required' => false, 'icon' => 'break']);
}
// ----------------------------------------------------- // -----------------------------------------------------
$query = new CustomerFormTypeQuery($customer); $query = new CustomerFormTypeQuery($customer);
$query->setUser($options['user']); // @phpstan-ignore-line $query->setUser($options['user']); // @phpstan-ignore-line
@@ -328,10 +332,6 @@ class TimesheetEditForm extends AbstractType
$builder->add('duration', DurationType::class, $durationOptions); $builder->add('duration', DurationType::class, $durationOptions);
if ($this->systemConfiguration->isBreakTimeEnabled()) {
$builder->add('break', DurationType::class, ['label' => 'break', 'required' => false, 'icon' => 'break']);
}
$builder->addEventListener( $builder->addEventListener(
FormEvents::POST_SET_DATA, FormEvents::POST_SET_DATA,
function (FormEvent $event): void { function (FormEvent $event): void {

View File

@@ -25,10 +25,12 @@ final class CalendarToolbarForm extends AbstractType
'view_timezone' => $options['timezone'], 'view_timezone' => $options['timezone'],
]); ]);
$builder->add('view', CalendarViewType::class, []); $builder->add('view', CalendarViewType::class, []);
$builder->add('user', UserType::class, [ if ($options['change_user']) {
'required' => false, $builder->add('user', UserType::class, [
'attr' => ['onchange' => 'this.form.submit()'] 'required' => false,
]); 'attr' => ['onchange' => 'this.form.submit()']
]);
}
} }
public function configureOptions(OptionsResolver $resolver): void public function configureOptions(OptionsResolver $resolver): void

View File

@@ -39,7 +39,7 @@ final class TagsType extends AbstractType
public function getParent(): string public function getParent(): string
{ {
if ($this->count === null) { if ($this->count === null) {
$this->count = $this->repository->count([]); $this->count = $this->repository->count(['visible' => true]);
} }
if ($this->count > self::MAX_AMOUNT_SELECT) { if ($this->count > self::MAX_AMOUNT_SELECT) {

View File

@@ -75,6 +75,8 @@ final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
'entry.duration_format' => $formatter->getFormattedDuration($item->getDuration()), 'entry.duration_format' => $formatter->getFormattedDuration($item->getDuration()),
'entry.duration_decimal' => $formatter->getFormattedDecimalDuration($item->getDuration()), 'entry.duration_decimal' => $formatter->getFormattedDecimalDuration($item->getDuration()),
'entry.duration_minutes' => (int) ($item->getDuration() / 60), 'entry.duration_minutes' => (int) ($item->getDuration() / 60),
// prepare optional field with empty string
'entry.activity' => '',
]; ];
if ($begin !== null) { if ($begin !== null) {

View File

@@ -14,6 +14,7 @@ use App\Form\Type\MonthPickerType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -41,6 +42,8 @@ final class ProjectDateRangeForm extends AbstractType
'label' => 'includeNoWork', 'label' => 'includeNoWork',
]); ]);
$builder->add('view', HiddenType::class);
$builder->add('budgetType', ChoiceType::class, [ $builder->add('budgetType', ChoiceType::class, [
'placeholder' => null, 'placeholder' => null,
'required' => false, 'required' => false,

View File

@@ -18,6 +18,7 @@ final class ProjectDateRangeQuery
private ?Customer $customer = null; private ?Customer $customer = null;
private bool $includeNoWork = false; private bool $includeNoWork = false;
private ?string $budgetType = null; private ?string $budgetType = null;
private ?string $view = '0';
public function __construct(\DateTime $month, private User $user) public function __construct(\DateTime $month, private User $user)
{ {
@@ -83,4 +84,14 @@ final class ProjectDateRangeQuery
{ {
$this->budgetType = $budgetType; $this->budgetType = $budgetType;
} }
public function getView(): ?string
{
return $this->view;
}
public function setView(?string $view): void
{
$this->view = $view;
}
} }

View File

@@ -9,7 +9,18 @@
namespace App\User; namespace App\User;
use App\Entity\Team;
use App\Event\TeamCreateEvent;
use App\Event\TeamCreatePostEvent;
use App\Event\TeamCreatePreEvent;
use App\Event\TeamDeleteEvent;
use App\Event\TeamUpdatePostEvent;
use App\Event\TeamUpdatePreEvent;
use App\Repository\TeamRepository; use App\Repository\TeamRepository;
use App\Validator\ValidationFailedException;
use InvalidArgumentException;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class TeamService final class TeamService
{ {
@@ -18,10 +29,19 @@ final class TeamService
*/ */
private array $cache = []; private array $cache = [];
public function __construct(private TeamRepository $repository) public function __construct(
private readonly TeamRepository $repository,
private readonly ValidatorInterface $validator,
private readonly EventDispatcherInterface $dispatcher,
)
{ {
} }
public function findTeamByName(string $name): ?Team
{
return $this->repository->findOneBy(['name' => $name]);
}
public function countTeams(): int public function countTeams(): int
{ {
if (!\array_key_exists('count', $this->cache)) { if (!\array_key_exists('count', $this->cache)) {
@@ -31,8 +51,67 @@ final class TeamService
return $this->cache['count']; return $this->cache['count'];
} }
public function createNewTeam(string $name): Team
{
$team = new Team($name);
$this->dispatcher->dispatch(new TeamCreateEvent($team));
return $team;
}
public function saveTeam(Team $team): Team
{
if(null === $team->getId()) {
// invalidate cache only on new teams
return $this->saveNewTeam($team);
}
return $this->updateTeam($team);
}
private function saveNewTeam(Team $team): Team
{
if (null !== $team->getId()) {
throw new InvalidArgumentException('Cannot create team, already persisted');
}
$this->validateTeam($team);
$this->dispatcher->dispatch(new TeamCreatePreEvent($team));
$this->repository->saveTeam($team);
$this->dispatcher->dispatch(new TeamCreatePostEvent($team));
return $team;
}
public function hasTeams(): bool public function hasTeams(): bool
{ {
return $this->countTeams() > 0; return $this->countTeams() > 0;
} }
private function validateTeam(Team $team): void
{
$errors = $this->validator->validate($team);
if ($errors->count() > 0) {
throw new ValidationFailedException($errors);
}
}
private function updateTeam(Team $team): Team
{
$this->validateTeam($team);
$this->dispatcher->dispatch(new TeamUpdatePreEvent($team));
$this->repository->saveTeam($team);
$this->dispatcher->dispatch(new TeamUpdatePostEvent($team));
return $team;
}
public function deleteTeam(Team $delete): void
{
$this->dispatcher->dispatch(new TeamDeleteEvent($delete));
$this->repository->deleteTeam($delete);
}
} }

View File

@@ -95,7 +95,7 @@ final class ParsedownExtension extends Parsedown
return null; return null;
} }
protected function blockTable($Line, array $Block = null) // @phpstan-ignore missingType.return,missingType.iterableValue,missingType.parameter protected function blockTable($Line, ?array $Block = null) // @phpstan-ignore missingType.return,missingType.iterableValue,missingType.parameter
{ {
$Block = parent::blockTable($Line, $Block); $Block = parent::blockTable($Line, $Block);

View File

@@ -11,7 +11,7 @@ namespace App\Validator;
final class ValidationException extends \RuntimeException final class ValidationException extends \RuntimeException
{ {
public function __construct(string $message = null) public function __construct(?string $message = null)
{ {
if ($message === null) { if ($message === null) {
$message = 'Validation Failed'; $message = 'Validation Failed';

View File

@@ -181,7 +181,7 @@ final class WorkingTimeService
return $year->getMonth($monthDate); return $year->getMonth($monthDate);
} }
// deprecated 3.0 remove $user, fetch from $month->getUser() instead // FIXME deprecated 3.0 remove $user, fetch from $month->getUser() instead
public function approveMonth(User $user, Month $month, \DateTimeInterface $approvalDate, User $approvedBy): void public function approveMonth(User $user, Month $month, \DateTimeInterface $approvalDate, User $approvedBy): void
{ {
foreach ($month->getDays() as $day) { foreach ($month->getDays() as $day) {

View File

@@ -311,9 +311,9 @@ mpdf-->
{% if id in ['duration', 'duration_decimal', 'duration_seconds'] %} {% if id in ['duration', 'duration_decimal', 'duration_seconds'] %}
<td class="totals duration">{{ duration|duration(id == 'duration_decimal') }}</td> <td class="totals duration">{{ duration|duration(id == 'duration_decimal') }}</td>
{% elseif id == 'internal_rate' %} {% elseif id == 'internal_rate' %}
<td class="totals cost">3{% if currency is not null %}{{ rateInternal|money(currency) }}{% endif %}</td> <td class="totals cost">{% if currency is not null %}{{ rateInternal|money(currency) }}{% endif %}</td>
{% elseif id == 'rate' %} {% elseif id == 'rate' %}
<td class="totals cost">4{% if currency is not null %}{{ rate|money(currency) }}{% endif %}</td> <td class="totals cost">{% if currency is not null %}{{ rate|money(currency) }}{% endif %}</td>
{% else %} {% else %}
<td></td> <td></td>
{% endif %} {% endif %}

View File

@@ -28,6 +28,7 @@
'actions': {'class': 'actions alwaysVisible'}, 'actions': {'class': 'actions alwaysVisible'},
}) %} }) %}
{% set tableName = 'project_daterange_reporting' %} {% set tableName = 'project_daterange_reporting' %}
{% set queryValue = app.request.query.get('view') %}
{% block main_before %} {% block main_before %}
{{ tables.data_table_column_modal(tableName, columns) }} {{ tables.data_table_column_modal(tableName, columns) }}
@@ -51,6 +52,9 @@
{{ form_widget(form.includeNoWork) }} {{ form_widget(form.includeNoWork) }}
</li> </li>
</ul> </ul>
<button onclick="{{ form.view.vars.id }}.value = {{ queryValue == '1' ? 0 : 1 }}" type="submit" class="btn btn-icon {% if queryValue != '1' %}active{% endif %}">
{{ icon('collapse', true) }}
</button>
</div> </div>
{% endblock %} {% endblock %}
@@ -70,42 +74,158 @@
{% else %} {% else %}
{{ tables.datatable_header(tableName, columns, null, {'boxClass': ''}) }} {{ tables.datatable_header(tableName, columns, null, {'boxClass': ''}) }}
{% for id, mapping in entries|sort((a, b) => a.customer.name <=> b.customer.name) %} {% if queryValue != '1' %}
<tr class="summary"> {% for id, mapping in entries|sort((a, b) => a.customer.name <=> b.customer.name) %}
<td colspan="{{ columns|length }}">{{ widgets.label_customer(mapping.customer) }}</td> {% set currency = mapping.customer.currency %}
</tr> {% set totalDuration = 0 %}
{% for entry in mapping.projects|sort((a, b) => a.entity.name <=> b.entity.name) %} {% set totalRevenue = 0 %}
{% set project = entry.entity %} {% set totalCosts = 0 %}
{% set currency = project.customer.currency %}
<tr {{ widgets.project_row_attr(project, queryEnd) }}> {% for entry in mapping.projects %}
{% for name, column_config in columns %} {% set totalDuration = totalDuration + entry.statistic.duration %}
<td class="{{ tables.data_table_column_class(tableName, columns, name) }}"> {% set totalRevenue = totalRevenue + entry.statistic.rateBillable %}
{% if name == 'name' %} {% set totalCosts = totalCosts + entry.statistic.internalRate %}
{{ widgets.label_project(project) }}
{% elseif name == 'duration' %}
{{ entry.statistic.duration|duration }}
{% elseif name == 'revenue' %}
{{ entry.statistic.rateBillable|money(currency) }}
{% elseif name == 'costs' %}
{{ entry.statistic.internalRate|money(currency) }}
{% elseif name == 'profit' %}
{{ (entry.statistic.rateBillable - entry.statistic.internalRate)|money(currency) }}
{% elseif name == 'timeBudget' %}
{% if is_granted('time', project) %}
{{ progress.progressbar_timebudget(entry) }}
{% endif %}
{% elseif name == 'budget' %}
{% if is_granted('budget', project) %}
{{ progress.progressbar_budget(entry, currency) }}
{% endif %}
{% elseif name == 'actions' %}
{{ projectActions.project(project, 'custom', true, {'daterange': date_range(queryBegin, queryEnd)}) }}
{% endif %}
</td>
{% endfor %} {% endfor %}
{% set totalProfit = totalRevenue - totalCosts %}
<tr class="summary">
{% for name, column_config in columns %}
<td class="{{ tables.data_table_column_class(tableName, columns, name) }}">
{% if name == 'name' %}
{{ widgets.label_customer(mapping.customer) }}
{% elseif name == 'duration' %}
{{ totalDuration|duration }}
{% elseif name == 'revenue' %}
{{ totalRevenue|money(currency) }}
{% elseif name == 'costs' %}
{{ totalCosts|money(currency) }}
{% elseif name == 'profit' %}
{{ totalProfit|money(currency) }}
{% endif %}
</td>
{% endfor %}
</tr>
{% for entry in mapping.projects|sort((a, b) => a.entity.name <=> b.entity.name) %}
{% set project = entry.entity %}
<tr {{ widgets.project_row_attr(project, queryEnd) }}>
{% for name, column_config in columns %}
<td class="{{ tables.data_table_column_class(tableName, columns, name) }}">
{% if name == 'name' %}
{{ widgets.label_project(project) }}
{% elseif name == 'duration' %}
{{ entry.statistic.duration|duration }}
{% elseif name == 'revenue' %}
{{ entry.statistic.rateBillable|money(currency) }}
{% elseif name == 'costs' %}
{{ entry.statistic.internalRate|money(currency) }}
{% elseif name == 'profit' %}
{{ (entry.statistic.rateBillable - entry.statistic.internalRate)|money(currency) }}
{% elseif name == 'timeBudget' %}
{% if is_granted('time', project) %}
{{ progress.progressbar_timebudget(entry) }}
{% endif %}
{% elseif name == 'budget' %}
{% if is_granted('budget', project) %}
{{ progress.progressbar_budget(entry, currency) }}
{% endif %}
{% elseif name == 'actions' %}
{{ projectActions.project(project, 'custom', true, {'daterange': date_range(queryBegin, queryEnd)}) }}
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
{% endfor %}
{% else %}
{% for id, mapping in entries|sort((a, b) => a.customer.name <=> b.customer.name) %}
{% set currency = mapping.customer.currency %}
{% set totalDuration = 0 %}
{% set totalRevenue = 0 %}
{% set totalCosts = 0 %}
{% for entry in mapping.projects %}
{% set totalDuration = totalDuration + entry.statistic.duration %}
{% set totalRevenue = totalRevenue + entry.statistic.rateBillable %}
{% set totalCosts = totalCosts + entry.statistic.internalRate %}
{% endfor %}
{% set totalProfit = totalRevenue - totalCosts %}
<tr>
{% for name, column_config in columns %}
<td class="{{ tables.data_table_column_class(tableName, columns, name) }}">
{% if name == 'name' %}
{{ widgets.label_customer(mapping.customer) }}
{% elseif name == 'duration' %}
{{ totalDuration|duration }}
{% elseif name == 'revenue' %}
{{ totalRevenue|money(currency) }}
{% elseif name == 'costs' %}
{{ totalCosts|money(currency) }}
{% elseif name == 'profit' %}
{{ totalProfit|money(currency) }}
{% endif %}
</td>
{% endfor %}
</tr> </tr>
{% endfor %} {% endfor %}
{% endif %}
{% set grandTotalDuration = 0 %}
{% set grandTotalRevenue = 0 %}
{% set grandTotalCosts = 0 %}
{% set grandTotalProfit = 0 %}
{% set currencyTotal = null %}
{% for id, mapping in entries %}
{% set currency = mapping.customer.currency %}
{% if currencyTotal is null %}
{% set currencyTotal = currency %}
{% endif %}
{% if currency != currencyTotal %}
{% set currencyTotal = false %}
{% endif %}
{% set totalDuration = 0 %}
{% set totalRevenue = 0 %}
{% set totalCosts = 0 %}
{# accumulate project data per customer #}
{% for entry in mapping.projects %}
{% set totalDuration = totalDuration + entry.statistic.duration %}
{% set totalRevenue = totalRevenue + entry.statistic.rateBillable %}
{% set totalCosts = totalCosts + entry.statistic.internalRate %}
{% endfor %}
{% set totalProfit = totalRevenue - totalCosts %}
{# accumulate grand totals #}
{% set grandTotalDuration = grandTotalDuration + totalDuration %}
{% set grandTotalRevenue = grandTotalRevenue + totalRevenue %}
{% set grandTotalCosts = grandTotalCosts + totalCosts %}
{% set grandTotalProfit = grandTotalProfit + totalProfit %}
{% endfor %} {% endfor %}
{% if currencyTotal is same as (false) %}
{% set currencyTotal = null %}
{% endif %}
<tr class="summary">
{% for name, column_config in columns %}
<td class="{{ tables.data_table_column_class(tableName, columns, name) }}">
{% if name == 'name' %}
<strong>{{ 'sum.total'|trans }}</strong>
{% elseif name == 'duration' %}
<strong>{{ grandTotalDuration|duration }}</strong>
{% elseif name == 'revenue' %}
<strong>{{ grandTotalRevenue|money(currencyTotal) }}</strong>
{% elseif name == 'costs' %}
<strong>{{ grandTotalCosts|money(currencyTotal) }}</strong>
{% elseif name == 'profit' %}
<strong>{{ grandTotalProfit|money(currencyTotal) }}</strong>
{% endif %}
</td>
{% endfor %}
</tr>
{{ tables.data_table_footer(entries) }} {{ tables.data_table_footer(entries) }}
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@@ -304,12 +304,13 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'user' => ['result' => 'object', 'type' => '@User'], 'user' => ['result' => 'object', 'type' => '@User'],
'dueDays' => 'int', 'dueDays' => 'int',
'invoiceNumber' => 'string', 'invoiceNumber' => 'string',
'metaFields' => 'array',
'paymentDate' => '@datetime', 'paymentDate' => '@datetime',
'status' => 'string', 'status' => 'string',
'tax' => 'float', 'tax' => 'float',
'total' => 'float', 'total' => 'float',
'vat' => 'float', 'vat' => 'float',
'overdue' => 'bool',
'metaFields' => ['result' => 'array', 'type' => 'CustomerMeta'],
]; ];
case 'PageActionItem': case 'PageActionItem':
@@ -326,8 +327,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
return [ return [
'id' => 'int', 'id' => 'int',
'name' => 'string', 'name' => 'string',
'color' => '@string', 'color' => 'string',
'color-safe' => 'string',
'visible' => 'bool', 'visible' => 'bool',
]; ];
@@ -354,14 +354,19 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
return [ return [
'id' => 'int', 'id' => 'int',
'username' => 'string', 'username' => 'string',
'email' => 'string',
'enabled' => 'bool', 'enabled' => 'bool',
'apiToken' => 'bool', 'apiToken' => 'bool',
'color' => '@string', 'systemAccount' => 'bool',
'color' => 'string',
'avatar' => '@string', 'avatar' => '@string',
'alias' => '@string', 'alias' => '@string',
'accountNumber' => '@string', 'accountNumber' => '@string',
'initials' => '@string', 'initials' => '@string',
'title' => '@string', 'title' => '@string',
'language' => 'string',
'locale' => 'string',
'timezone' => 'string',
]; ];
// if a user is loaded explicitly // if a user is loaded explicitly
@@ -369,20 +374,23 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
return [ return [
'id' => 'int', 'id' => 'int',
'username' => 'string', 'username' => 'string',
'email' => 'string',
'enabled' => 'bool', 'enabled' => 'bool',
'apiToken' => 'bool', 'apiToken' => 'bool',
'alias' => '@string', 'systemAccount' => 'bool',
'title' => '@string', 'color' => 'string',
'supervisor' => ['result' => 'object', 'type' => '@UserEntity'],
'avatar' => '@string', 'avatar' => '@string',
'color' => '@string', 'alias' => '@string',
'teams' => ['result' => 'array', 'type' => 'Team'], 'accountNumber' => '@string',
'roles' => ['result' => 'array', 'type' => 'string'],
'initials' => 'string', 'initials' => 'string',
'title' => '@string',
'language' => 'string', 'language' => 'string',
'locale' => 'string', 'locale' => 'string',
'timezone' => 'string', 'timezone' => 'string',
'accountNumber' => '@string', // TODO more info in entity than in collection
'supervisor' => ['result' => 'object', 'type' => '@UserEntity'],
'teams' => ['result' => 'array', 'type' => 'Team'],
'roles' => ['result' => 'array', 'type' => 'string'],
'memberships' => ['result' => 'array', 'type' => 'TeamMembership'], 'memberships' => ['result' => 'array', 'type' => 'TeamMembership'],
'preferences' => ['result' => 'array', 'type' => 'UserPreference'], 'preferences' => ['result' => 'array', 'type' => 'UserPreference'],
]; ];
@@ -394,7 +402,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
return [ return [
'id' => 'int', 'id' => 'int',
'name' => 'string', 'name' => 'string',
'color' => '@string', 'color' => 'string',
]; ];
// explicitly requested team // explicitly requested team
@@ -402,8 +410,9 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
return [ return [
'id' => 'int', 'id' => 'int',
'name' => 'string', 'name' => 'string',
'color' => '@string', 'color' => 'string',
'members' => ['result' => 'array', 'type' => 'TeamMember'], 'members' => ['result' => 'array', 'type' => 'TeamMember'],
// TODO more info in entity than in collection
'customers' => ['result' => 'array', 'type' => '@Customer'], 'customers' => ['result' => 'array', 'type' => '@Customer'],
'projects' => ['result' => 'array', 'type' => '@Project'], 'projects' => ['result' => 'array', 'type' => '@Project'],
'activities' => ['result' => 'array', 'type' => '@Activity'], 'activities' => ['result' => 'array', 'type' => '@Activity'],
@@ -430,12 +439,18 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string', 'name' => 'string',
'visible' => 'bool', 'visible' => 'bool',
'billable' => 'bool', 'billable' => 'bool',
'color' => '@string', 'color' => 'string',
'number' => '@string', 'number' => '@string',
'comment' => '@string', 'comment' => '@string',
'currency' => 'string', // since 2.40.0 'currency' => 'string',
'country' => 'string', // since 2.40.0 'country' => 'string',
'company' => '@string', // since 2.40.0 'company' => '@string',
'homepage' => '@string',
'timezone' => 'string',
'fax' => '@string',
'mobile' => '@string',
'phone' => '@string',
'metaFields' => ['result' => 'array', 'type' => 'CustomerMeta'], // since 2.45
]; ];
// if a list of customers is loaded // if a list of customers is loaded
@@ -445,19 +460,19 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string', 'name' => 'string',
'visible' => 'boolean', 'visible' => 'boolean',
'billable' => 'bool', 'billable' => 'bool',
'color' => '@string', 'color' => 'string',
'number' => '@string', 'number' => '@string',
'comment' => '@string', 'comment' => '@string',
'metaFields' => ['result' => 'array', 'type' => 'CustomerMeta'], 'currency' => 'string',
'teams' => ['result' => 'array', 'type' => 'Team'],
'currency' => 'string', // since 1.10
'country' => 'string', 'country' => 'string',
'company' => '@string', 'company' => '@string',
'homepage' => '@string', 'homepage' => '@string',
'timezone' => 'string',
'fax' => '@string', 'fax' => '@string',
'mobile' => '@string', 'mobile' => '@string',
'phone' => '@string', 'phone' => '@string',
'timezone' => 'string', 'metaFields' => ['result' => 'array', 'type' => 'CustomerMeta'],
'teams' => ['result' => 'array', 'type' => 'Team'],
]; ];
// if a customer is loaded explicitly // if a customer is loaded explicitly
@@ -467,32 +482,34 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string', 'name' => 'string',
'visible' => 'bool', 'visible' => 'bool',
'billable' => 'bool', 'billable' => 'bool',
'color' => '@string', 'color' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'CustomerMeta'],
'teams' => ['result' => 'array', 'type' => 'Team'],
'homepage' => '@string',
'number' => '@string', 'number' => '@string',
'comment' => '@string', 'comment' => '@string',
'company' => '@string',
'contact' => '@string',
'address' => '@string',
'country' => 'string',
'currency' => 'string', 'currency' => 'string',
'phone' => '@string', 'country' => 'string',
'company' => '@string',
'homepage' => '@string',
'timezone' => 'string',
'fax' => '@string', 'fax' => '@string',
'mobile' => '@string', 'mobile' => '@string',
'phone' => '@string',
'metaFields' => ['result' => 'array', 'type' => 'CustomerMeta'],
'teams' => ['result' => 'array', 'type' => 'Team'],
// TODO more info in entity than in collection
'contact' => '@string',
'email' => '@string', 'email' => '@string',
'timezone' => 'string', 'vatId' => '@string',
'addressLine1' => '@string',
'addressLine2' => '@string',
'addressLine3' => '@string',
'city' => '@string',
'postCode' => '@string',
'buyerReference' => '@string',
// only available in the entity itself
'address' => '@string', // deprecated, do not expose in collection
'budget' => 'float', 'budget' => 'float',
'timeBudget' => 'int', 'timeBudget' => 'int',
'vatId' => '@string', // since 1.10 'budgetType' => '@string',
'budgetType' => '@string', // since 1.15
'addressLine1' => '@string', // since 2.32
'addressLine2' => '@string', // since 2.32
'addressLine3' => '@string', // since 2.32
'city' => '@string', // since 2.32
'postCode' => '@string', // since 2.32
'buyerReference' => '@string', // since 2.41
]; ];
// if a project is embedded // if a project is embedded
@@ -502,14 +519,16 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string', 'name' => 'string',
'visible' => 'bool', 'visible' => 'bool',
'billable' => 'bool', 'billable' => 'bool',
'color' => '@string', 'color' => 'string',
'customer' => 'int', 'customer' => 'int',
'number' => '@string', 'number' => '@string',
'orderNumber' => '@string', 'orderNumber' => '@string',
'orderDate' => '@date',
'globalActivities' => 'bool', 'globalActivities' => 'bool',
'comment' => '@string', 'comment' => '@string',
'start' => '@datetime', 'start' => '@datetime',
'end' => '@datetime', 'end' => '@datetime',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
]; ];
// if a project is embedded in an expanded collection (here timesheet) // if a project is embedded in an expanded collection (here timesheet)
@@ -519,14 +538,16 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string', 'name' => 'string',
'visible' => 'bool', 'visible' => 'bool',
'billable' => 'bool', 'billable' => 'bool',
'color' => '@string', 'color' => 'string',
'customer' => ['result' => 'object', 'type' => 'Customer'], 'customer' => ['result' => 'object', 'type' => 'Customer'],
'number' => '@string', 'number' => '@string',
'orderNumber' => '@string', 'orderNumber' => '@string',
'orderDate' => '@date',
'globalActivities' => 'bool', 'globalActivities' => 'bool',
'comment' => '@string', 'comment' => '@string',
'start' => '@datetime', 'start' => '@datetime',
'end' => '@datetime', 'end' => '@datetime',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
]; ];
// if a collection of projects is loaded // if a collection of projects is loaded
@@ -536,17 +557,18 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string', 'name' => 'string',
'visible' => 'bool', 'visible' => 'bool',
'billable' => 'bool', 'billable' => 'bool',
'color' => 'string',
'customer' => 'int', 'customer' => 'int',
'number' => '@string', 'number' => '@string',
'orderNumber' => '@string', 'orderNumber' => '@string',
'color' => '@string', 'orderDate' => '@date',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], 'globalActivities' => 'bool',
'parentTitle' => 'string', 'comment' => '@string',
'start' => '@datetime', 'start' => '@datetime',
'end' => '@datetime', 'end' => '@datetime',
'globalActivities' => 'bool', 'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'teams' => ['result' => 'array', 'type' => 'Team'], 'teams' => ['result' => 'array', 'type' => 'Team'],
'comment' => '@string', 'parentTitle' => 'string',
]; ];
// if a project is explicitly loaded // if a project is explicitly loaded
@@ -556,21 +578,22 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string', 'name' => 'string',
'visible' => 'bool', 'visible' => 'bool',
'billable' => 'bool', 'billable' => 'bool',
'color' => 'string',
'customer' => 'int', 'customer' => 'int',
'number' => '@string', 'number' => '@string',
'color' => '@string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'parentTitle' => 'string',
'start' => '@date',
'end' => '@date',
'globalActivities' => 'bool',
'teams' => ['result' => 'array', 'type' => 'Team'],
'comment' => '@string',
'budget' => 'float',
'timeBudget' => 'int',
'orderNumber' => '@string', 'orderNumber' => '@string',
'orderDate' => '@date', 'orderDate' => '@date',
'budgetType' => '@string', // since 1.15 'globalActivities' => 'bool',
'comment' => '@string',
'start' => '@date',
'end' => '@date',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'teams' => ['result' => 'array', 'type' => 'Team'],
'parentTitle' => 'string',
// only available in the entity itself
'budget' => 'float',
'timeBudget' => 'int',
'budgetType' => '@string',
]; ];
// embedded activities // embedded activities
@@ -582,7 +605,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'billable' => 'bool', 'billable' => 'bool',
'project' => '@int', 'project' => '@int',
'number' => '@string', 'number' => '@string',
'color' => '@string', 'color' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], // since 2.45
'comment' => '@string', 'comment' => '@string',
]; ];
@@ -594,7 +618,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'billable' => 'bool', 'billable' => 'bool',
'project' => ['result' => 'object', 'type' => '@ProjectExpanded'], 'project' => ['result' => 'object', 'type' => '@ProjectExpanded'],
'number' => '@string', 'number' => '@string',
'color' => '@string', 'color' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], // since 2.45
'comment' => '@string', 'comment' => '@string',
]; ];
@@ -607,10 +632,10 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'billable' => 'bool', 'billable' => 'bool',
'project' => '@int', 'project' => '@int',
'number' => '@string', 'number' => '@string',
'color' => '@string', 'color' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], 'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'parentTitle' => '@string',
'comment' => '@string', 'comment' => '@string',
'parentTitle' => '@string',
'teams' => ['result' => 'array', 'type' => 'Team'], 'teams' => ['result' => 'array', 'type' => 'Team'],
]; ];
@@ -623,14 +648,15 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'billable' => 'bool', 'billable' => 'bool',
'project' => '@int', 'project' => '@int',
'number' => '@string', 'number' => '@string',
'color' => '@string', 'color' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], 'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'parentTitle' => '@string',
'comment' => '@string', 'comment' => '@string',
'parentTitle' => '@string',
'teams' => ['result' => 'array', 'type' => 'Team'],
// only available in the entity itself
'budget' => 'float', 'budget' => 'float',
'timeBudget' => 'int', 'timeBudget' => 'int',
'teams' => ['result' => 'array', 'type' => 'Team'], 'budgetType' => '@string',
'budgetType' => '@string', // since 1.15
]; ];
case 'TimesheetEntity': case 'TimesheetEntity':
@@ -651,7 +677,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'billable' => 'bool', 'billable' => 'bool',
'fixedRate' => '@float', 'fixedRate' => '@float',
'hourlyRate' => '@float', 'hourlyRate' => '@float',
// TODO new fields: category 'break' => 'int',
]; ];
case 'TimesheetExpanded': case 'TimesheetExpanded':
@@ -672,7 +698,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'billable' => 'bool', 'billable' => 'bool',
'fixedRate' => '@float', 'fixedRate' => '@float',
'hourlyRate' => '@float', 'hourlyRate' => '@float',
// TODO new fields: category 'break' => 'int',
]; ];
case 'TimesheetCollection': case 'TimesheetCollection':
@@ -691,6 +717,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'internalRate' => 'float', 'internalRate' => 'float',
'exported' => 'bool', 'exported' => 'bool',
'billable' => 'bool', 'billable' => 'bool',
'break' => 'int',
]; ];
case 'TimesheetCollectionFull': case 'TimesheetCollectionFull':
@@ -709,6 +736,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'internalRate' => 'float', 'internalRate' => 'float',
'exported' => 'bool', 'exported' => 'bool',
'billable' => 'bool', 'billable' => 'bool',
'break' => 'int',
]; ];
default: default:

View File

@@ -258,7 +258,7 @@ class ActivityControllerTest extends APIControllerBaseTestCase
self::assertNull($result['budgetType']); self::assertNull($result['budgetType']);
self::assertNull($result['number']); self::assertNull($result['number']);
self::assertEquals('Test comment', $result['comment']); self::assertEquals('Test comment', $result['comment']);
self::assertNull($result['color']); self::assertEquals('#5319e7', $result['color']);
self::assertTrue($result['visible']); self::assertTrue($result['visible']);
self::assertTrue($result['billable']); self::assertTrue($result['billable']);
} }
@@ -297,7 +297,7 @@ class ActivityControllerTest extends APIControllerBaseTestCase
self::assertEquals('Test', $result['parentTitle']); self::assertEquals('Test', $result['parentTitle']);
self::assertNotEmpty($result['id']); self::assertNotEmpty($result['id']);
self::assertIsArray($result['teams']); self::assertIsArray($result['teams']);
self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => null]], $result['teams']); self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => '#03A9F4']], $result['teams']);
self::assertIsArray($result['metaFields']); self::assertIsArray($result['metaFields']);
self::assertEquals([], $result['metaFields']); self::assertEquals([], $result['metaFields']);
self::assertEquals('foo', $result['name']); self::assertEquals('foo', $result['name']);

View File

@@ -262,7 +262,7 @@ class CustomerControllerTest extends APIControllerBaseTestCase
self::assertNull($result['homepage']); self::assertNull($result['homepage']);
self::assertEquals('Europe/Berlin', $result['timezone']); self::assertEquals('Europe/Berlin', $result['timezone']);
self::assertNull($result['buyerReference']); self::assertNull($result['buyerReference']);
self::assertNull($result['color']); self::assertEquals('#5319e7', $result['color']);
self::assertTrue($result['visible']); self::assertTrue($result['visible']);
self::assertTrue($result['billable']); self::assertTrue($result['billable']);
} }
@@ -316,7 +316,7 @@ class CustomerControllerTest extends APIControllerBaseTestCase
self::assertApiResponseTypeStructure('CustomerEntity', $result); self::assertApiResponseTypeStructure('CustomerEntity', $result);
self::assertNotEmpty($result['id']); self::assertNotEmpty($result['id']);
self::assertIsArray($result['teams']); self::assertIsArray($result['teams']);
self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => null]], $result['teams']); self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => '#03A9F4']], $result['teams']);
self::assertIsArray($result['metaFields']); self::assertIsArray($result['metaFields']);
self::assertEquals([], $result['metaFields']); self::assertEquals([], $result['metaFields']);
self::assertEquals('foo', $result['name']); self::assertEquals('foo', $result['name']);

View File

@@ -329,7 +329,7 @@ class ProjectControllerTest extends APIControllerBaseTestCase
self::assertNull($result['orderNumber']); self::assertNull($result['orderNumber']);
self::assertNull($result['number']); self::assertNull($result['number']);
self::assertNull($result['comment']); self::assertNull($result['comment']);
self::assertNull($result['color']); self::assertEquals('#2ECC40', $result['color']);
self::assertTrue($result['globalActivities']); self::assertTrue($result['globalActivities']);
self::assertTrue($result['billable']); self::assertTrue($result['billable']);
self::assertTrue($result['visible']); self::assertTrue($result['visible']);
@@ -374,7 +374,7 @@ class ProjectControllerTest extends APIControllerBaseTestCase
self::assertEquals('Test', $result['parentTitle']); self::assertEquals('Test', $result['parentTitle']);
self::assertNotEmpty($result['id']); self::assertNotEmpty($result['id']);
self::assertIsArray($result['teams']); self::assertIsArray($result['teams']);
self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => null]], $result['teams']); self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => '#03A9F4']], $result['teams']);
self::assertIsArray($result['metaFields']); self::assertIsArray($result['metaFields']);
self::assertEquals([], $result['metaFields']); self::assertEquals([], $result['metaFields']);
self::assertEquals('foo', $result['name']); self::assertEquals('foo', $result['name']);

View File

@@ -34,6 +34,7 @@ class ActivityTest extends AbstractEntityTestCase
self::assertTrue($sut->isBillable()); self::assertTrue($sut->isBillable());
self::assertTrue($sut->isGlobal()); self::assertTrue($sut->isGlobal());
self::assertNull($sut->getColor()); self::assertNull($sut->getColor());
self::assertIsString($sut->getColorSafe());
self::assertFalse($sut->hasColor()); self::assertFalse($sut->hasColor());
self::assertInstanceOf(Collection::class, $sut->getMetaFields()); self::assertInstanceOf(Collection::class, $sut->getMetaFields());
self::assertEquals(0, $sut->getMetaFields()->count()); self::assertEquals(0, $sut->getMetaFields()->count());
@@ -71,6 +72,9 @@ class ActivityTest extends AbstractEntityTestCase
$sut->setColor('#fffccc'); $sut->setColor('#fffccc');
self::assertEquals('#fffccc', $sut->getColor()); self::assertEquals('#fffccc', $sut->getColor());
self::assertTrue($sut->hasColor()); self::assertTrue($sut->hasColor());
self::assertNotEmpty($sut->getColorSafe());
$sut->setName('alsjdkhfalsf');
self::assertEquals('#fffccc', $sut->getColorSafe());
$sut->setColor(Constants::DEFAULT_COLOR); $sut->setColor(Constants::DEFAULT_COLOR);
self::assertNull($sut->getColor()); self::assertNull($sut->getColor());

View File

@@ -53,6 +53,7 @@ class CustomerTest extends AbstractEntityTestCase
self::assertNull($sut->getTimezone()); self::assertNull($sut->getTimezone());
self::assertNull($sut->getColor()); self::assertNull($sut->getColor());
self::assertEquals('#e135f4', $sut->getColorSafe());
self::assertFalse($sut->hasColor()); self::assertFalse($sut->hasColor());
self::assertInstanceOf(Collection::class, $sut->getMetaFields()); self::assertInstanceOf(Collection::class, $sut->getMetaFields());
self::assertEquals(0, $sut->getMetaFields()->count()); self::assertEquals(0, $sut->getMetaFields()->count());

View File

@@ -38,6 +38,7 @@ class ProjectTest extends AbstractEntityTestCase
self::assertTrue($sut->isBillable()); self::assertTrue($sut->isBillable());
self::assertTrue($sut->isGlobalActivities()); self::assertTrue($sut->isGlobalActivities());
self::assertNull($sut->getColor()); self::assertNull($sut->getColor());
self::assertIsString($sut->getColorSafe());
self::assertFalse($sut->hasColor()); self::assertFalse($sut->hasColor());
self::assertInstanceOf(Collection::class, $sut->getMetaFields()); self::assertInstanceOf(Collection::class, $sut->getMetaFields());
self::assertEquals(0, $sut->getMetaFields()->count()); self::assertEquals(0, $sut->getMetaFields()->count());
@@ -60,9 +61,21 @@ class ProjectTest extends AbstractEntityTestCase
self::assertInstanceOf(Project::class, $sut->setCustomer($customer)); self::assertInstanceOf(Project::class, $sut->setCustomer($customer));
self::assertSame($customer, $sut->getCustomer()); self::assertSame($customer, $sut->getCustomer());
self::assertFalse($sut->hasColor());
$sut->setColor('#fffccc');
self::assertEquals('#fffccc', $sut->getColor());
self::assertIsString($sut->getColorSafe());
self::assertTrue($sut->hasColor());
$sut->setColor(Constants::DEFAULT_COLOR);
self::assertNull($sut->getColor());
self::assertFalse($sut->hasColor());
self::assertInstanceOf(Project::class, $sut->setName('123456789')); self::assertInstanceOf(Project::class, $sut->setName('123456789'));
self::assertEquals('123456789', (string) $sut); self::assertEquals('123456789', (string) $sut);
self::assertEquals('#FF9800', $sut->getColorSafe());
self::assertInstanceOf(Project::class, $sut->setOrderNumber('123456789')); self::assertInstanceOf(Project::class, $sut->setOrderNumber('123456789'));
self::assertEquals('123456789', $sut->getOrderNumber()); self::assertEquals('123456789', $sut->getOrderNumber());
@@ -88,15 +101,6 @@ class ProjectTest extends AbstractEntityTestCase
$sut->setInvoiceText('very long invoice text comment 12324'); $sut->setInvoiceText('very long invoice text comment 12324');
self::assertEquals('very long invoice text comment 12324', $sut->getInvoiceText()); self::assertEquals('very long invoice text comment 12324', $sut->getInvoiceText());
self::assertFalse($sut->hasColor());
$sut->setColor('#fffccc');
self::assertEquals('#fffccc', $sut->getColor());
self::assertTrue($sut->hasColor());
$sut->setColor(Constants::DEFAULT_COLOR);
self::assertNull($sut->getColor());
self::assertFalse($sut->hasColor());
self::assertInstanceOf(Project::class, $sut->setVisible(false)); self::assertInstanceOf(Project::class, $sut->setVisible(false));
self::assertFalse($sut->isVisible()); self::assertFalse($sut->isVisible());

View File

@@ -28,9 +28,11 @@ class TagTest extends TestCase
{ {
$sut = new Tag(); $sut = new Tag();
self::assertIsString($sut->getColorSafe());
self::assertInstanceOf(Tag::class, $sut->setName('foo')); self::assertInstanceOf(Tag::class, $sut->setName('foo'));
self::assertEquals('foo', $sut->getName()); self::assertEquals('foo', $sut->getName());
self::assertEquals('foo', (string) $sut); self::assertEquals('foo', (string) $sut);
self::assertEquals('#e135f4', $sut->getColorSafe());
$sut->setName(null); $sut->setName(null);
self::assertNull($sut->getName()); self::assertNull($sut->getName());

View File

@@ -43,6 +43,7 @@ class TeamTest extends TestCase
{ {
$sut = new Team('foo'); $sut = new Team('foo');
self::assertNull($sut->getColor()); self::assertNull($sut->getColor());
self::assertEquals('#e135f4', $sut->getColorSafe());
self::assertFalse($sut->hasColor()); self::assertFalse($sut->hasColor());
$sut->setColor(Constants::DEFAULT_COLOR); $sut->setColor(Constants::DEFAULT_COLOR);

View File

@@ -155,26 +155,6 @@ class TimesheetTest extends TestCase
self::assertTrue($sut->isBillable()); self::assertTrue($sut->isBillable());
} }
public function testCategory(): void
{
$sut = new Timesheet();
self::assertInstanceOf(Timesheet::class, $sut->setCategory(Timesheet::HOLIDAY));
self::assertEquals('holiday', $sut->getCategory());
self::assertInstanceOf(Timesheet::class, $sut->setCategory(Timesheet::WORK));
self::assertEquals('work', $sut->getCategory());
self::assertInstanceOf(Timesheet::class, $sut->setCategory(Timesheet::SICKNESS));
self::assertEquals('sickness', $sut->getCategory());
self::assertInstanceOf(Timesheet::class, $sut->setCategory(Timesheet::PARENTAL));
self::assertEquals('parental', $sut->getCategory());
self::assertInstanceOf(Timesheet::class, $sut->setCategory(Timesheet::OVERTIME));
self::assertEquals('overtime', $sut->getCategory());
self::expectException(\InvalidArgumentException::class);
self::expectExceptionMessage('Invalid timesheet category "foo" given, expected one of: work, holiday, sickness, parental, overtime');
$sut->setCategory('foo');
}
public function testClone(): void public function testClone(): void
{ {
$sut = new Timesheet(); $sut = new Timesheet();

View File

@@ -34,6 +34,7 @@ class UserTest extends TestCase
self::assertInstanceOf(EquatableInterface::class, $user); self::assertInstanceOf(EquatableInterface::class, $user);
self::assertInstanceOf(UserInterface::class, $user); self::assertInstanceOf(UserInterface::class, $user);
self::assertInstanceOf(ArrayCollection::class, $user->getPreferences()); self::assertInstanceOf(ArrayCollection::class, $user->getPreferences());
self::assertEquals([], $user->getVisiblePreferences());
self::assertNull($user->getTitle()); self::assertNull($user->getTitle());
self::assertNull($user->getAvatar()); self::assertNull($user->getAvatar());
self::assertNull($user->getAlias()); self::assertNull($user->getAlias());
@@ -186,9 +187,13 @@ class UserTest extends TestCase
self::assertNull($sut->getColor()); self::assertNull($sut->getColor());
self::assertFalse($sut->hasColor()); self::assertFalse($sut->hasColor());
$sut->setUsername('foo test 123');
self::assertEquals('#a972c9', $sut->getColorSafe());
$sut->setColor('#000000'); $sut->setColor('#000000');
self::assertEquals('#000000', $sut->getColor()); self::assertEquals('#000000', $sut->getColor());
self::assertTrue($sut->hasColor()); self::assertTrue($sut->hasColor());
self::assertEquals('#000000', $sut->getColorSafe());
} }
public function testWizards(): void public function testWizards(): void
@@ -268,6 +273,19 @@ class UserTest extends TestCase
$prefs = $user->getPreferences(); $prefs = $user->getPreferences();
self::assertCount(3, $prefs); self::assertCount(3, $prefs);
$preference1 = new UserPreference('_aaaaa', 'bbbbb');
$user->addPreference($preference1);
$preference2 = new UserPreference('AAAAAA', 'CCCCCC');
$user->addPreference($preference2);
$prefs = $user->getPreferences();
self::assertCount(5, $prefs);
$visiblePrefs = $user->getVisiblePreferences();
self::assertCount(3, $visiblePrefs); // export_decimal and _aaaaa is skipped
self::assertEquals([$user->getPreference('test'), $user->getPreference('test2'), $preference2], $visiblePrefs);
self::assertInstanceOf(UserPreference::class, $prefs[0]); self::assertInstanceOf(UserPreference::class, $prefs[0]);
self::assertEquals('test', $prefs[0]->getName()); self::assertEquals('test', $prefs[0]->getName());

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Team;
use App\Event\AbstractTeamEvent;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\EventDispatcher\Event;
abstract class AbstractTeamEventTestCase extends TestCase
{
abstract protected function createTeamEvent(Team $team): AbstractTeamEvent;
public function testGetterAndSetter(): void
{
$team = new Team('foo');
$sut = $this->createTeamEvent($team);
self::assertInstanceOf(Event::class, $sut);
self::assertSame($team, $sut->getTeam());
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Team;
use App\Event\AbstractTeamEvent;
use App\Event\TeamCreateEvent;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(AbstractTeamEventTestCase::class)]
#[CoversClass(TeamCreateEvent::class)]
class TeamCreateEventTest extends AbstractTeamEventTestCase
{
protected function createTeamEvent(Team $team): AbstractTeamEvent
{
return new TeamCreateEvent($team);
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Team;
use App\Event\AbstractTeamEvent;
use App\Event\TeamCreatePostEvent;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(AbstractTeamEventTestCase::class)]
#[CoversClass(TeamCreatePostEvent::class)]
class TeamCreatePostEventTest extends AbstractTeamEventTestCase
{
protected function createTeamEvent(Team $team): AbstractTeamEvent
{
return new TeamCreatePostEvent($team);
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Team;
use App\Event\AbstractTeamEvent;
use App\Event\TeamCreatePreEvent;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(AbstractTeamEventTestCase::class)]
#[CoversClass(TeamCreatePreEvent::class)]
class TeamCreatePreEventTest extends AbstractTeamEventTestCase
{
protected function createTeamEvent(Team $team): AbstractTeamEvent
{
return new TeamCreatePreEvent($team);
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Team;
use App\Event\AbstractTeamEvent;
use App\Event\TeamDeleteEvent;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(AbstractTeamEventTestCase::class)]
#[CoversClass(TeamDeleteEvent::class)]
class TeamDeleteEventTest extends AbstractTeamEventTestCase
{
protected function createTeamEvent(Team $team): AbstractTeamEvent
{
return new TeamDeleteEvent($team);
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Team;
use App\Event\AbstractTeamEvent;
use App\Event\TeamUpdatePostEvent;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(AbstractTeamEventTestCase::class)]
#[CoversClass(TeamUpdatePostEvent::class)]
class TeamUpdatePostEventTest extends AbstractTeamEventTestCase
{
protected function createTeamEvent(Team $team): AbstractTeamEvent
{
return new TeamUpdatePostEvent($team);
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Team;
use App\Event\AbstractTeamEvent;
use App\Event\TeamUpdatePreEvent;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(AbstractTeamEventTestCase::class)]
#[CoversClass(TeamUpdatePreEvent::class)]
class TeamUpdatePreEventTest extends AbstractTeamEventTestCase
{
protected function createTeamEvent(Team $team): AbstractTeamEvent
{
return new TeamUpdatePreEvent($team);
}
}