0
0
Fork 0
mirror of https://github.com/kevinpapst/kimai2.git synced 2025-04-07 22:45:50 +00:00

Merge branch 'master' into PdfExportChanges

This commit is contained in:
VladimirPodhajecky 2022-01-27 14:10:06 +01:00 committed by GitHub
commit 15e69c419a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
85 changed files with 1689 additions and 1163 deletions
.github/workflows
.github_changelog_generatorSECURITY.mdcomposer.jsoncomposer.lock
config/packages/prod
phpstan.neon
src
templates
tests
translations

View file

@ -51,6 +51,9 @@ jobs:
- name: Validate Composer
run: composer validate --strict
- name: Warmup cache for PHPStan
run: APP_ENV=dev bin/console kimai:reload -n
- name: Check codestyles
run: vendor/bin/php-cs-fixer fix --dry-run --verbose --config=.php-cs-fixer.dist.php --using-cache=no --show-progress=none --format=checkstyle | cs2pr

View file

@ -1,5 +1,5 @@
unreleased=true
future-release=1.16
future-release=1.17
exclude-labels=duplicate,support,question,invalid,wontfix,release,waiting for feedback,documentation
enhancement_labels=>enhancement,Enhancement,feature request,translation i18n,technical debt,documentation
issues-wo-labels=false

View file

@ -5,10 +5,10 @@
As announced in the [README](README.md) I only support the latest available release and master.
| Version | Supported |
| ------- | ------------------ |
|---------| ------------------ |
| master | :white_check_mark: |
| 1.16 | :white_check_mark: |
| < 1.16 | :x: |
| 1.17 | :white_check_mark: |
| < 1.17 | :x: |
## Reporting a Vulnerability

View file

@ -78,7 +78,7 @@
"dama/doctrine-test-bundle": "^6.0",
"doctrine/doctrine-fixtures-bundle": "^3.2",
"fakerphp/faker": "^1.15",
"friendsofphp/php-cs-fixer": "^3.0",
"friendsofphp/php-cs-fixer": "3.2.*",
"phpstan/phpstan": "^1.0",
"phpstan/phpstan-doctrine": "^1.0",
"phpstan/phpstan-phpunit": "^1.0",

1744
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,7 @@ monolog:
- ^/
nested:
type: stream
level: info
path: "%kernel.logs_dir%/%kernel.environment%.log"
console:

View file

@ -1,8 +1,9 @@
includes:
- vendor/phpstan/phpstan-symfony/extension.neon
- vendor/phpstan/phpstan-doctrine/extension.neon
- vendor/phpstan/phpstan-symfony/rules.neon
- vendor/phpstan/phpstan/conf/bleedingEdge.neon
- %rootDir%/../phpstan-symfony/extension.neon
- %rootDir%/../phpstan-symfony/rules.neon
- %rootDir%/../phpstan-doctrine/extension.neon
- %rootDir%/../phpstan-doctrine/rules.neon
- %rootDir%/../phpstan/conf/bleedingEdge.neon
parameters:
tmpDir: %rootDir%/../../../var/cache/phpstan
@ -11,4 +12,8 @@ parameters:
excludePaths:
- %rootDir%/../../../src/Ldap/LdapDriver.php
treatPhpDocTypesAsCertain: false
inferPrivatePropertyTypeFromConstructor: true
inferPrivatePropertyTypeFromConstructor: true
doctrine:
objectManagerLoader: %rootDir%/../../../tests/phpstan-doctrine.php
symfony:
containerXmlPath: %rootDir%/../../../var/cache/dev/srcApp_KernelDevDebugContainer.xml

View file

@ -15,7 +15,6 @@ use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Team;
use App\Entity\TeamMember;
use App\Entity\User;
use App\Form\API\TeamApiEditForm;
use App\Repository\ActivityRepository;
@ -201,7 +200,7 @@ final class TeamController extends BaseApiController
* Update an existing team
*
* @SWG\Patch(
* description="Update an existing team, you can pass all or just a subset of all attributes (passing users will replace all existing ones)",
* description="Update an existing team, you can pass all or just a subset of all attributes (passing members will replace all existing ones)",
* @SWG\Response(
* response=200,
* description="Returns the updated team",
@ -235,11 +234,12 @@ final class TeamController extends BaseApiController
throw new NotFoundException();
}
// cache the current memberlist
/** @var TeamMember[] $originalMembers */
$originalMembers = [];
foreach ($team->getMembers() as $member) {
$originalMembers[] = $member;
if ($request->request->has('members')) {
foreach ($team->getMembers() as $member) {
$team->removeMember($member);
$this->repository->removeTeamMember($member);
}
$this->repository->saveTeam($team);
}
$form = $this->createForm(TeamApiEditForm::class, $team);
@ -254,14 +254,6 @@ final class TeamController extends BaseApiController
return $this->viewHandler->handle($view);
}
// and now remove the ones, which are not in the list any longer
foreach ($originalMembers as $member) {
if (!$team->hasMember($member)) {
$member->getUser()->removeMembership($member);
$this->repository->removeTeamMember($member);
}
}
$this->repository->saveTeam($team);
$view = new View($team, Response::HTTP_OK);

View file

@ -100,7 +100,7 @@ final class CreateUserCommand extends Command
*/
protected function askForPassword(InputInterface $input, OutputInterface $output): string
{
/* @var QuestionHelper $helper */
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$passwordQuestion = new Question('Please enter the password: ');

View file

@ -105,7 +105,7 @@ trait StringAccessibleConfigTrait
/**
* @param string $key
* @return mixed
* @return string|int|bool|float|null|array
*/
public function find(string $key)
{

View file

@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '1.16.10';
public const VERSION = '1.17.1';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 11610;
public const VERSION_ID = 11701;
/**
* The current release status, either "stable" or "dev"
*/

View file

@ -295,7 +295,7 @@ final class InvoiceController extends AbstractController
$csrfTokenManager->refreshToken('invoice.status');
try {
$this->service->deleteInvoice($invoice);
$this->service->deleteInvoice($invoice, $this->dispatcher);
$this->flashSuccess('action.delete.success');
} catch (Exception $ex) {
$this->flashDeleteException($ex);

View file

@ -9,7 +9,6 @@
namespace App\Controller;
use App\Entity\TeamMember;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Event\PrepareUserEvent;
@ -19,7 +18,9 @@ use App\Form\UserPasswordType;
use App\Form\UserPreferencesForm;
use App\Form\UserRolesType;
use App\Form\UserTeamsType;
use App\Repository\TeamRepository;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
use App\Timesheet\TimesheetStatisticService;
use App\User\UserService;
use Doctrine\Common\Collections\ArrayCollection;
@ -78,15 +79,13 @@ final class ProfileController extends AbstractController
* @Route(path="/{username}/edit", name="user_profile_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', profile)")
*/
public function editAction(User $profile, Request $request): Response
public function editAction(User $profile, Request $request, UserRepository $userRepository): Response
{
$form = $this->createEditForm($profile);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($profile);
$entityManager->flush();
$userRepository->saveUser($profile);
$this->flashSuccess('action.update.success');
@ -152,7 +151,7 @@ final class ProfileController extends AbstractController
* @Route(path="/{username}/roles", name="user_profile_roles", methods={"GET", "POST"})
* @Security("is_granted('roles', profile)")
*/
public function rolesAction(User $profile, Request $request): Response
public function rolesAction(User $profile, Request $request, UserRepository $userRepository): Response
{
$isSuperAdmin = $profile->isSuperAdmin();
@ -166,9 +165,7 @@ final class ProfileController extends AbstractController
$profile->setSuperAdmin(true);
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($profile);
$entityManager->flush();
$userRepository->saveUser($profile);
$this->flashSuccess('action.update.success');
@ -186,7 +183,7 @@ final class ProfileController extends AbstractController
* @Route(path="/{username}/teams", name="user_profile_teams", methods={"GET", "POST"})
* @Security("is_granted('teams', profile)")
*/
public function teamsAction(User $profile, Request $request, UserService $service): Response
public function teamsAction(User $profile, Request $request, UserRepository $userRepository, TeamRepository $teamRepository): Response
{
$originalMembers = new ArrayCollection();
foreach ($profile->getMemberships() as $member) {
@ -197,18 +194,15 @@ final class ProfileController extends AbstractController
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager = $this->getDoctrine()->getManager();
/** @var TeamMember $member */
foreach ($originalMembers as $member) {
if (!$profile->hasMembership($member)) {
$member->getTeam()->removeMember($member);
$entityManager->remove($profile);
$member->setTeam(null);
$member->setUser(null);
$teamRepository->removeTeamMember($member);
}
}
$entityManager->persist($profile);
$entityManager->flush();
$userRepository->saveUser($profile);
$this->flashSuccess('action.update.success');

View file

@ -100,6 +100,7 @@ class AppExtension extends Extension
$container->setParameter('kimai.i18n_domains', $localTranslations);
// this should happen always at the end, so bundles do not mess with the base configuration
/* @phpstan-ignore-next-line */
if ($container->hasParameter('kimai.bundles.config')) {
$bundleConfig = $container->getParameter('kimai.bundles.config');
if (!\is_array($bundleConfig)) {

View file

@ -99,7 +99,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
/**
* Description of this activity
*
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Activity_Entity"})

View file

@ -51,7 +51,7 @@ trait BudgetTrait
* - null = default / full time
* - month = monthly budget
*
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Activity_Entity", "Project_Entity", "Customer_Entity"})

View file

@ -20,7 +20,7 @@ trait ColorTrait
/**
* The assigned color in HTML hex format, eg. #dd1d00
*
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
@ -32,9 +32,6 @@ trait ColorTrait
*/
private $color = null;
/**
* @return string
*/
public function getColor(): ?string
{
if ($this->color === Constants::DEFAULT_COLOR) {
@ -49,9 +46,6 @@ trait ColorTrait
return null !== $this->color && $this->color !== Constants::DEFAULT_COLOR;
}
/**
* @param string $color
*/
public function setColor(?string $color = null): void
{
$this->color = $color;

View file

@ -30,10 +30,8 @@ class Configuration
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(name="id", type="integer")
* @phpstan-ignore-next-line
*/
private $id;
/**
* @var string
*
@ -42,9 +40,8 @@ class Configuration
* @Assert\Length(min=2, max=100, allowEmptyString=false)
*/
private $name;
/**
* @var string
* @var string|null
*
* @ORM\Column(name="value", type="string", length=1024, nullable=true)
* @Assert\Length(max=1024, allowEmptyString=true)

View file

@ -64,7 +64,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $name;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
@ -76,7 +76,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $number;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
@ -99,7 +99,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $visible = true;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
@ -111,7 +111,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $company;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
@ -123,7 +123,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $vatId;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
@ -135,7 +135,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $contact;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
@ -174,7 +174,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $currency = self::DEFAULT_CURRENCY;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
@ -186,7 +186,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $phone;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
@ -198,7 +198,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $fax;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
@ -214,7 +214,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*
* Limited via RFC to 254 chars
*
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
@ -226,7 +226,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $email;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})

View file

@ -53,7 +53,6 @@ class Invoice implements EntityWithMetaFields
* @ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* @var string
*
@ -63,9 +62,8 @@ class Invoice implements EntityWithMetaFields
* @Assert\NotNull()
*/
private $invoiceNumber;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
@ -75,25 +73,22 @@ class Invoice implements EntityWithMetaFields
* @ORM\Column(name="comment", type="text", nullable=true)
*/
private $comment;
/**
* @var Customer|null
* @var Customer
*
* @ORM\ManyToOne(targetEntity="App\Entity\Customer")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $customer;
/**
* @var User|null
* @var User
*
* @ORM\ManyToOne(targetEntity="App\Entity\User")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $user;
/**
* @var \DateTime
*
@ -103,14 +98,12 @@ class Invoice implements EntityWithMetaFields
* @Assert\NotNull()
*/
private $createdAt;
/**
* @var string
*
* @ORM\Column(name="timezone", type="string", length=64, nullable=false)
*/
private $timezone;
/**
* @var float
*
@ -120,7 +113,6 @@ class Invoice implements EntityWithMetaFields
* @Assert\NotNull()
*/
private $total = 0.00;
/**
* @var float
*
@ -130,7 +122,6 @@ class Invoice implements EntityWithMetaFields
* @Assert\NotNull()
*/
private $tax = 0.00;
/**
* @var string
*
@ -141,7 +132,6 @@ class Invoice implements EntityWithMetaFields
* @Assert\Length(max=3)
*/
private $currency;
/**
* @var int
*
@ -152,7 +142,6 @@ class Invoice implements EntityWithMetaFields
* @Assert\Range(min = 0, max = 999)
*/
private $dueDays = 30;
/**
* @var float
*
@ -163,7 +152,6 @@ class Invoice implements EntityWithMetaFields
* @Assert\Range(min = 0.0, max = 99.99)
*/
private $vat = 0.00;
/**
* @var string
*
@ -173,7 +161,6 @@ class Invoice implements EntityWithMetaFields
* @Assert\NotNull()
*/
private $status = self::STATUS_NEW;
/**
* @var string
*
@ -184,19 +171,16 @@ class Invoice implements EntityWithMetaFields
* @Assert\Length(min=1, max=150, allowEmptyString=false)
*/
private $invoiceFilename;
/**
* @var bool
*/
private $localized = false;
/**
* @var \DateTime|null
*
* @ORM\Column(name="payment_date", type="date", nullable=true)
*/
private $paymentDate;
/**
* Meta fields
*

View file

@ -32,7 +32,6 @@ class InvoiceTemplate
* @ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* @var string
*
@ -41,7 +40,6 @@ class InvoiceTemplate
* @Assert\Length(min=1, max=60, allowEmptyString=false)
*/
private $name;
/**
* @var string
*
@ -49,7 +47,6 @@ class InvoiceTemplate
* @Assert\NotBlank()
*/
private $title;
/**
* @var string
*
@ -57,29 +54,25 @@ class InvoiceTemplate
* @Assert\NotBlank()
*/
private $company;
/**
* @var string
* @var string|null
*
* @ORM\Column(name="vat_id", type="string", length=50, nullable=true)
* @Assert\Length(max=50)
*/
private $vatId;
/**
* @var string
* @var string|null
*
* @ORM\Column(name="address", type="text", nullable=true)
*/
private $address;
/**
* @var string
* @var string|null
*
* @ORM\Column(name="contact", type="text", nullable=true)
*/
private $contact;
/**
* @var int
*
@ -87,7 +80,6 @@ class InvoiceTemplate
* @Assert\Range(min = 0, max = 999)
*/
private $dueDays = 30;
/**
* @var float
*
@ -95,7 +87,6 @@ class InvoiceTemplate
* @Assert\Range(min = 0.0, max = 99.99)
*/
private $vat = 0.00;
/**
* @var string
*
@ -112,7 +103,6 @@ class InvoiceTemplate
* @Assert\Length(max=20)
*/
private $numberGenerator = 'default';
/**
* @var string
*
@ -121,21 +111,18 @@ class InvoiceTemplate
* @Assert\Length(max=20)
*/
private $renderer = 'default';
/**
* @var string
* @var string|null
*
* @ORM\Column(name="payment_terms", type="text", nullable=true)
*/
private $paymentTerms;
/**
* @var string
* @var string|null
*
* @ORM\Column(name="payment_details", type="text", nullable=true)
*/
private $paymentDetails;
/**
* Used when rendering HTML templates.
*
@ -145,11 +132,10 @@ class InvoiceTemplate
* @Assert\NotNull()
*/
private $decimalDuration = false;
/**
* Used for translations and locale dependent number and date formats.
*
* @var string
* @var string|null
*
* @ORM\Column(name="language", type="string", length=6, nullable=true)
*/

View file

@ -45,7 +45,7 @@ trait MetaTableTypeTrait
/**
* Value of the meta (custom) field
*
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Default"})

View file

@ -106,7 +106,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget
/**
* Project order number
*
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Project_Entity"})
@ -118,7 +118,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget
*/
private $orderNumber;
/**
* @var \DateTime
* @var \DateTime|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Project_Entity"})
@ -133,7 +133,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget
*/
private $orderDate;
/**
* @var \DateTime
* @var \DateTime|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Project"})
@ -148,7 +148,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget
*/
private $start;
/**
* @var \DateTime
* @var \DateTime|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Project"})
@ -163,7 +163,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget
*/
private $end;
/**
* @var string
* @var string|null
* @internal used for storing the timezone for "order", "start" and "end" date
*
* @ORM\Column(name="timezone", type="string", length=64, nullable=true)
@ -175,7 +175,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget
*/
private $localized = false;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Project_Entity"})

View file

@ -28,7 +28,7 @@ trait Rate
*/
private $id;
/**
* @var User
* @var User|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Default"})

View file

@ -30,7 +30,6 @@ class Role
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
* @phpstan-ignore-next-line
*/
private $id;
/**

View file

@ -30,7 +30,6 @@ class RolePermission
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
* @phpstan-ignore-next-line
*/
private $id;
/**

View file

@ -39,7 +39,6 @@ class Tag
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
* @phpstan-ignore-next-line
*/
private $id;
/**

View file

@ -59,7 +59,7 @@ class Team
/**
* All team member (including team leads)
*
* @var TeamMember[]|Collection<TeamMember>
* @var Collection<TeamMember>
*
* @Serializer\Expose()
* @Serializer\Groups({"Team_Entity"})
@ -135,22 +135,11 @@ class Team
}
/**
* Indexed by ID to use it within collection type forms.
*
* @return TeamMember[]
* @return Collection<TeamMember>
*/
public function getMembers(): iterable
public function getMembers(): Collection
{
$all = [];
foreach ($this->members as $member) {
if ($member->getId() === null) {
$all[] = $member;
} else {
$all[$member->getId()] = $member;
}
}
return $all;
return $this->members;
}
public function addMember(TeamMember $member): void
@ -167,12 +156,12 @@ class Team
throw new \InvalidArgumentException('Cannot set foreign team membership');
}
// when using the API an invalid user id does not trigger the validation first, but after calling this method :-(
// when using the API an invalid User ID triggers the validation too late
if ($member->getUser() === null) {
return;
}
if (null !== ($existing = $this->findMember($member))) {
if (null !== $this->findMemberByUser($member->getUser())) {
return;
}
@ -185,22 +174,11 @@ class Team
return $this->members->contains($member);
}
private function findMember(TeamMember $member): ?TeamMember
{
foreach ($this->members as $oldMember) {
if ($oldMember->getUser() === $member->getUser() && $oldMember->getTeam() === $member->getTeam()) {
return $oldMember;
}
}
return null;
}
private function findMemberByUser(User $user): ?TeamMember
{
foreach ($this->members as $oldMember) {
if ($oldMember->getUser() === $user) {
return $oldMember;
foreach ($this->members as $member) {
if ($member->getUser() === $user) {
return $member;
}
}
@ -209,12 +187,14 @@ class Team
public function removeMember(TeamMember $member): void
{
if (null === ($existingMember = $this->findMember($member))) {
if (!$this->members->contains($member)) {
return;
}
$this->members->removeElement($existingMember);
$existingMember->getUser()->removeMembership($existingMember);
$this->members->removeElement($member);
$member->getUser()->removeMembership($member);
$member->setTeam(null);
$member->setUser(null);
}
/**

View file

@ -32,7 +32,6 @@ class TeamMember
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
* @phpstan-ignore-next-line
*/
private $id;
/**
@ -108,7 +107,7 @@ class TeamMember
public function __clone()
{
if ($this->id !== null) {
$id = null;
$this->id = null;
}
}
}

View file

@ -134,7 +134,7 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
*/
private $begin;
/**
* @var DateTime
* @var DateTime|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
@ -160,7 +160,7 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
*/
private $localized = false;
/**
* @var int
* @var int|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
@ -202,7 +202,7 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
*/
private $project;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
@ -240,7 +240,7 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
*/
private $fixedRate = null;
/**
* @var float
* @var float|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Entity"})

View file

@ -100,7 +100,7 @@ class User implements UserInterface, EquatableInterface, \Serializable
/**
* The user alias will be displayed in the frontend instead of the username
*
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
@ -114,7 +114,7 @@ class User implements UserInterface, EquatableInterface, \Serializable
/**
* Registration date for the user
*
* @var DateTime
* @var DateTime|null
*
* @Exporter\Expose(label="profile.registration_date", type="datetime")
*
@ -124,7 +124,7 @@ class User implements UserInterface, EquatableInterface, \Serializable
/**
* An additional title for the user, like the Job position or Department
*
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"User_Entity"})
@ -136,9 +136,9 @@ class User implements UserInterface, EquatableInterface, \Serializable
*/
private $title;
/**
* URL to the users avatar, will be auto-generated if empty
* URL to the user avatar, will be auto-generated if empty
*
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"User_Entity"})
@ -150,7 +150,7 @@ class User implements UserInterface, EquatableInterface, \Serializable
/**
* API token (password) for this user
*
* @var string
* @var string|null
*
* @ORM\Column(name="api_token", type="string", length=255, nullable=true)
*/
@ -167,7 +167,7 @@ class User implements UserInterface, EquatableInterface, \Serializable
*
* List of preferences for this user, required ones have dedicated fields/methods
*
* @var UserPreference[]|Collection
* @var Collection<UserPreference>
*
* @ORM\OneToMany(targetEntity="App\Entity\UserPreference", mappedBy="user", cascade={"persist"})
*/
@ -175,7 +175,7 @@ class User implements UserInterface, EquatableInterface, \Serializable
/**
* List of all team memberships.
*
* @var TeamMember[]|ArrayCollection<TeamMember>
* @var Collection<TeamMember>
*
* @Serializer\Expose()
* @Serializer\Groups({"User_Entity"})
@ -187,9 +187,9 @@ class User implements UserInterface, EquatableInterface, \Serializable
*/
private $memberships;
/**
* The type of authentication used by the user (eg. "kimai", "ldap", "saml")
* The type of authentication used by the user (e.g. "kimai", "ldap", "saml")
*
* @var string
* @var string|null
* @internal for internal usage only
*
* @ORM\Column(name="auth", type="string", length=20, nullable=true)
@ -579,12 +579,12 @@ class User implements UserInterface, EquatableInterface, \Serializable
throw new \InvalidArgumentException('Cannot set foreign user membership');
}
// when using the API an invalid user id does not trigger the validation first, but after calling this method :-(
// when using the API an invalid Team ID triggers the validation too late
if ($member->getTeam() === null) {
return;
}
if (null !== ($existing = $this->findMember($member))) {
if (null !== $this->findMemberByTeam($member->getTeam())) {
return;
}
@ -592,33 +592,35 @@ class User implements UserInterface, EquatableInterface, \Serializable
$member->getTeam()->addMember($member);
}
private function findMemberByTeam(Team $team): ?TeamMember
{
foreach ($this->memberships as $member) {
if ($member->getTeam() === $team) {
return $member;
}
}
return null;
}
public function removeMembership(TeamMember $member): void
{
if (null === ($member = $this->findMember($member))) {
if (!$this->memberships->contains($member)) {
return;
}
$this->memberships->removeElement($member);
$member->getUser()->removeMembership($member);
$member->getTeam()->removeMember($member);
$member->setUser(null);
$member->setTeam(null);
}
/**
* Indexed by ID to use it within collection type forms.
*
* @return TeamMember[]
* @return Collection<TeamMember>
*/
public function getMemberships(): iterable
public function getMemberships(): Collection
{
$all = [];
foreach ($this->memberships as $member) {
if ($member->getId() === null) {
$all[] = $member;
} else {
$all[$member->getId()] = $member;
}
}
return $all;
return $this->memberships;
}
public function hasMembership(TeamMember $member): bool
@ -626,17 +628,6 @@ class User implements UserInterface, EquatableInterface, \Serializable
return $this->memberships->contains($member);
}
private function findMember(TeamMember $member): ?TeamMember
{
foreach ($this->memberships as $oldMember) {
if ($oldMember->getUser() === $member->getUser() && $oldMember->getTeam() === $member->getTeam()) {
return $oldMember;
}
}
return null;
}
/**
* Checks if the user is member of any team.
*
@ -750,10 +741,8 @@ class User implements UserInterface, EquatableInterface, \Serializable
public function isTeamleadOf(Team $team): bool
{
foreach ($this->memberships as $membership) {
if ($membership->getTeam() === $team) {
return $membership->isTeamlead();
}
if (null !== ($member = $this->findMemberByTeam($team))) {
return $member->isTeamlead();
}
return false;
@ -830,10 +819,7 @@ class User implements UserInterface, EquatableInterface, \Serializable
return $this->auth === null || $this->auth === self::AUTH_INTERNAL;
}
/**
* {@inheritdoc}
*/
public function addRole($role)
public function addRole(string $role)
{
$role = strtoupper($role);
if ($role === static::DEFAULT_ROLE) {

View file

@ -49,7 +49,7 @@ class UserPreference
* @var User
*
* @ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="preferences")
* @ORM\JoinColumn(onDelete="CASCADE")
* @ORM\JoinColumn(nullable=false, onDelete="CASCADE")
* @Assert\NotNull()
*/
private $user;
@ -65,7 +65,7 @@ class UserPreference
*/
private $name;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Default"})

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\Invoice;
use Symfony\Contracts\EventDispatcher\Event;
final class InvoiceDeleteEvent extends Event
{
private $invoice;
public function __construct(Invoice $invoice)
{
$this->invoice = $invoice;
}
public function getInvoice(): Invoice
{
return $this->invoice;
}
}

View file

@ -10,14 +10,14 @@
namespace App\Export\Base;
use App\Activity\ActivityStatisticService;
use App\Export\ExportItemInterface;
use App\Invoice\InvoiceItemInterface;
use App\Project\ProjectStatisticService;
use App\Repository\Query\TimesheetQuery;
trait RendererTrait
{
/**
* @param ExportItemInterface[] $exportItems
* @param InvoiceItemInterface[] $exportItems
* @return array
*/
protected function calculateSummary(array $exportItems)
@ -130,7 +130,7 @@ trait RendererTrait
}
/**
* @param ExportItemInterface[] $exportItems
* @param InvoiceItemInterface[] $exportItems
* @param TimesheetQuery $query
* @param ProjectStatisticService $projectStatisticService
* @return array
@ -209,7 +209,7 @@ trait RendererTrait
}
/**
* @param ExportItemInterface[] $exportItems
* @param InvoiceItemInterface[] $exportItems
* @param TimesheetQuery $query
* @param ActivityStatisticService $activityStatisticService
* @return array

View file

@ -10,6 +10,8 @@
namespace App\Export;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\User;
use App\Repository\Query\TimesheetQuery;
use App\Utils\FileHelper;
@ -19,40 +21,35 @@ final class ExportFilename
* @var string
*/
private $filename;
/**
* @var Customer|null
*/
private $customer;
/**
* @var Project|null
*/
private $project;
/**
* @var User|null
*/
private $user;
public function __construct(TimesheetQuery $query)
{
$filename = date('Ymd');
$hasName = false;
$customers = $query->getCustomers();
if (\count($customers) === 1) {
$filename .= '-' . $this->convert($this->getCustomerName($customers[0]));
$hasName = true;
$this->customer = $customers[0];
}
$projects = $query->getProjects();
if (\count($projects) === 1) {
if (!$hasName) {
$filename .= '-' . $this->convert($this->getCustomerName($projects[0]->getCustomer()));
}
$filename .= '-' . $this->convert($projects[0]->getName());
$hasName = true;
$this->project = $projects[0];
}
$users = $query->getUsers();
if (\count($users) === 1) {
$filename .= '-' . $this->convert($users[0]->getDisplayName());
$hasName = true;
$this->user = $users[0];
}
if (!$hasName) {
$filename .= '-kimai-export';
}
$filename = str_replace(['/', '\\'], '-', $filename);
$this->filename = $filename;
}
private function getCustomerName(Customer $customer): string
@ -72,6 +69,37 @@ final class ExportFilename
public function getFilename()
{
if ($this->filename === null) {
$filename = date('Ymd');
$hasName = false;
if ($this->customer !== null) {
$filename .= '-' . $this->convert($this->getCustomerName($this->customer));
$hasName = true;
}
if ($this->project !== null) {
if (!$hasName) {
$filename .= '-' . $this->convert($this->getCustomerName($this->project->getCustomer()));
}
$filename .= '-' . $this->convert($this->project->getName());
$hasName = true;
}
if ($this->user !== null) {
$filename .= '-' . $this->convert($this->user->getDisplayName());
$hasName = true;
}
if (!$hasName) {
$filename .= '-kimai-export';
}
$filename = str_replace(['/', '\\'], '-', $filename);
$this->filename = $filename;
}
return $this->filename;
}

View file

@ -12,9 +12,7 @@ namespace App\Invoice\Renderer;
use App\Entity\InvoiceDocument;
use App\Invoice\InvoiceModel;
use App\Invoice\RendererInterface;
use App\Twig\LocaleFormatExtensions;
use Symfony\Bridge\Twig\Extension\TranslationExtension;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use App\Twig\TwigRendererTrait;
use Twig\Environment;
/**
@ -22,6 +20,8 @@ use Twig\Environment;
*/
abstract class AbstractTwigRenderer implements RendererInterface
{
use TwigRendererTrait;
/**
* @var Environment
*/
@ -34,39 +34,16 @@ abstract class AbstractTwigRenderer implements RendererInterface
protected function renderTwigTemplate(InvoiceDocument $document, InvoiceModel $model): string
{
$previousLocale = $this->changeTwigLocale($this->twig, $model->getTemplate()->getLanguage());
$content = $this->twig->render('@invoice/' . basename($document->getFilename()), [
$language = $model->getTemplate()->getLanguage();
$formatLocale = $model->getFormatter()->getLocale();
$template = '@invoice/' . basename($document->getFilename());
$options = [
// model should not be used in the future, but we can likely not remove it
'model' => $model,
// new since 1.16.7 - templates should only use the pre-generated values
'invoice' => $model->toArray(),
]);
];
$this->changeTwigLocale($this->twig, $previousLocale);
return $content;
}
private function changeTwigLocale(Environment $twig, ?string $locale = null): ?string
{
// for invoices that don't have a language configured (using request locale)
if (null === $locale) {
return null;
}
/** @var TranslationExtension $extension */
$extension = $twig->getExtension(TranslationExtension::class);
/** @var LocaleAwareInterface $translator */
$translator = $extension->getTranslator();
$previousLocale = $translator->getLocale();
$translator->setLocale($locale);
/** @var LocaleFormatExtensions $extension */
$extension = $twig->getExtension(LocaleFormatExtensions::class);
$extension->setLocale($locale);
return $previousLocale;
return $this->renderTwigTemplateWithLanguage($this->twig, $template, $options, $language, $formatLocale);
}
}

View file

@ -14,6 +14,7 @@ use App\Constants;
use App\Entity\Invoice;
use App\Entity\InvoiceDocument;
use App\Event\InvoiceCreatedEvent;
use App\Event\InvoiceDeleteEvent;
use App\Event\InvoicePostRenderEvent;
use App\Event\InvoicePreRenderEvent;
use App\Repository\InvoiceDocumentRepository;
@ -418,12 +419,17 @@ final class ServiceInvoice
return $this->createInvoiceFromModel($model, $dispatcher);
}
public function deleteInvoice(Invoice $invoice)
public function deleteInvoice(Invoice $invoice, EventDispatcherInterface $dispatcher)
{
$invoiceDirectory = $this->getInvoicesDirectory();
if (is_file($invoiceDirectory . $invoice->getInvoiceFilename())) {
$this->fileHelper->removeFile($invoiceDirectory . $invoice->getInvoiceFilename());
}
$event = new InvoiceDeleteEvent($invoice);
$dispatcher->dispatch($event);
$this->invoiceRepository->deleteInvoice($invoice);
}

View file

@ -18,6 +18,7 @@ abstract class AbstractPluginExtension extends Extension
{
$bundleConfig = [$this->getAlias() => $configs];
/* @phpstan-ignore-next-line */
if ($container->hasParameter('kimai.bundles.config')) {
$bundleConfig = array_merge(
$container->getParameter('kimai.bundles.config'),

View file

@ -51,6 +51,11 @@ class ConfigurationRepository extends EntityRepository implements ConfigLoaderIn
self::$initialized = true;
}
public function getConfigurationByName(string $name): ?Configuration
{
return $this->findOneBy(['name' => $name]);
}
public function saveConfiguration(Configuration $configuration)
{
$entityManager = $this->getEntityManager();
@ -59,6 +64,14 @@ class ConfigurationRepository extends EntityRepository implements ConfigLoaderIn
$this->clearCache();
}
public function deleteConfiguration(Configuration $configuration)
{
$entityManager = $this->getEntityManager();
$entityManager->remove($configuration);
$entityManager->flush();
$this->clearCache();
}
/**
* @param string $prefix
* @return Configuration[]

View file

@ -137,7 +137,7 @@ final class LocaleFormatExtensions extends AbstractExtension
return $this->formatter;
}
private function getLocale()
public function getLocale(): string
{
if (null === $this->locale) {
$this->locale = \Locale::getDefault();

View file

@ -0,0 +1,66 @@
<?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\Twig;
use Symfony\Bridge\Twig\Extension\TranslationExtension;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Twig\Environment;
/**
* @internal
*/
trait TwigRendererTrait
{
protected function renderTwigTemplateWithLanguage(Environment $twig, string $template, array $options = [], ?string $language = null, ?string $formatLocale = null): string
{
$previousTranslation = null;
$previousFormatLocale = null;
if ($language !== null) {
$previousTranslation = $this->switchTranslationLocale($twig, $language);
}
if ($formatLocale !== null) {
$previousFormatLocale = $this->switchFormatLocale($twig, $formatLocale);
}
$content = $twig->render($template, $options);
if ($previousTranslation !== null) {
$this->switchTranslationLocale($twig, $previousTranslation);
}
if ($previousFormatLocale !== null) {
$this->switchFormatLocale($twig, $previousFormatLocale);
}
return $content;
}
protected function switchTranslationLocale(Environment $twig, string $language): string
{
/** @var TranslationExtension $extension */
$extension = $twig->getExtension(TranslationExtension::class);
/** @var LocaleAwareInterface $translator */
$translator = $extension->getTranslator();
$previous = $translator->getLocale();
$translator->setLocale($language);
return $previous;
}
protected function switchFormatLocale(Environment $twig, string $language): string
{
/** @var LocaleFormatExtensions $extension */
$extension = $twig->getExtension(LocaleFormatExtensions::class);
$previous = $extension->getLocale();
$extension->setLocale($language);
return $previous;
}
}

View file

@ -12,10 +12,13 @@ namespace App\Utils;
interface HtmlToPdfConverter
{
/**
* Returns the binary content of the PDF, which can be saved as file or send via Reponse.
* Returns the binary content of the PDF, which can be saved as file.
* Throws an exception if conversion fails.
*
* @param string $html
* @param array $options
* @return mixed
* @return string
* @throws \Exception
*/
public function convertToPdf(string $html, array $options = []);
public function convertToPdf(string $html, array $options = []): string;
}

View file

@ -56,10 +56,10 @@ class MPdfConverter implements HtmlToPdfConverter
/**
* @param string $html
* @param array $options
* @return mixed|string
* @return string
* @throws \Mpdf\MpdfException
*/
public function convertToPdf(string $html, array $options = [])
public function convertToPdf(string $html, array $options = []): string
{
$options = array_merge(
$this->sanitizeOptions($options),

View file

@ -89,6 +89,10 @@ final class CustomerVoter extends Voter
}
}
if ($user->canSeeAllData()) {
return true;
}
return false;
}

View file

@ -3,16 +3,18 @@
{% set showRateColumn = showRateColumn ?? is_granted('view_rate_other_timesheet') %}
{% set showRateBudget = showRateBudget ?? false %}
{% set showTimeBudget = showTimeBudget ?? false %}
{% set showCustomerSummary = showCustomerSummary ?? true %}
{% set showTotalSummary = showTotalSummary ?? true %}
{% set showDateTimeShort = showDateTimeShort ?? false %}
{% set now = create_date('now', app.user) %}
{% set decimal = decimal ?? false %}
{# this is only triggered, if a user exports from his personal timesheet screen #}
{% if query.user is not null %}
{% if query is defined and query.user is not null %}
{% set showUserColumn = false %}
{# if exporting via the admin screen, users without view_rate_own_timesheet might still see their own rates #}
{% set showRateColumn = is_granted('view_rate_own_timesheet') %}
{% endif %}
<html lang="{{ app.request.locale }}">
<html{% if app.request is defined and app.request is not null %} lang="{{ app.request.locale }}"{% endif %}>
<head>
{% block styles %}
<style>
@ -88,7 +90,7 @@
<tr>
<td align="left">
{{ 'export.page_of'|trans({'%page%': '{PAGENO}', '%pages%': '{nb}'}) }}
{% if not showUserColumn and query.user %}
{% if not showUserColumn and query is defined and query.user %}
&ndash;
{{ 'label.user'|trans }}: {{ query.user.displayName }}
{% endif %}
@ -108,11 +110,13 @@
mpdf-->
{% endblock %}
{% block summary %}
<h2 style="margin-bottom: 0; padding-bottom: 0">{{ 'export.document_title'|trans }}</h2>
<h2 style="margin-bottom: 4px; padding-bottom: 0">{% block title %}{{ 'export.document_title'|trans }}{% endblock %}</h2>
{% if query is defined %}
<p>
{{ 'export.period'|trans }}:
{{ query.begin|date_short }} - {{ query.end|date_short }}
</p>
{% endif %}
<h3>{{ 'export.summary'|trans }}</h3>
<table class="items">
<thead>
@ -229,7 +233,7 @@ mpdf-->
{% set customerInternalRate = customerInternalRate + summary.rate_internal %}
{% set customerCount = customerCount + 1 %}
{% endfor %}
{% if customer is not same as(null) %}
{% if showCustomerSummary and customer is not same as(null) %}
<tr class="summary">
<td colspan="3"></td>
{% if showTimeBudget %}
@ -308,21 +312,22 @@ mpdf-->
{% endif %}
<tr class="{{ cycle(['odd', 'even'], loop.index0) }}">
<td class="text-nowrap">
{{ entry.begin|date_time }}
{% block date_begin %}{% if not showDateTimeShort %}{{ entry.begin|date_time }}{% else %}{{ entry.begin|date_short }}{% endif %}{% endblock %}
{% if entry.end %}
<br>
{{ entry.end|date_time }}
{% block date_end %}{% if not showDateTimeShort %}<br>{{ entry.end|date_time }}{% endif %}{% endblock %}
{% endif %}
</td>
{% if showUserColumn %}
<td>{{ entry.user.displayName }}</td>
{% endif %}
<td>
{{ entry.project.customer.name }} - {{ entry.project.name }}{% if entry.activity is not null %} - {{ entry.activity.name }}{% endif %}
{% if entry.description is not empty %}
<br>
<i>{{ entry.description|desc2html }}</i>
{% endif %}
{% block description %}
{{ entry.project.customer.name }} - {{ entry.project.name }}{% if entry.activity is not null %} - {{ entry.activity.name }}{% endif %}
{% if entry.description is not empty %}
<br>
<i>{{ entry.description|desc2html }}</i>
{% endif %}
{% endblock %}
</td>
<td class="duration">{{ entry.duration|duration(decimal) }}</td>
{% if showRateColumn %}
@ -341,9 +346,9 @@ mpdf-->
{% endfor %}
<tr class="summary">
{% if showUserColumn %}
<td colspan="3"></td>
<td colspan="3">{{ 'sum.total'|trans }}</td>
{% else %}
<td colspan="2"></td>
<td colspan="2">{{ 'sum.total'|trans }}</td>
{% endif %}
<td class="totals duration">{{ duration|duration(decimal) }}</td>
{% if showRateColumn %}

View file

@ -0,0 +1,18 @@
{% extends 'export/pdf-layout.html.twig' %}
{% set showRateColumn = false %}
{% set showRateBudget = false %}
{% set showTimeBudget = false %}
{% set showUserColumn = true %}
{% set showCustomerSummary = false %}
{% set decimal = true %}
{% set showDateTimeShort = true %}
{% block title %}{{ 'timesheet.pdf.twig'|trans({}, 'export') }}{% endblock %}
{% block description %}
{% if entry.description is not empty %}
{{ entry.description|desc2html }}
{% elseif entry.activity is not null %}
{{ entry.activity.name }}
{% endif %}
<br>
<small>{{ entry.project.customer.name }}, {{ entry.project.name }}</small>
{% endblock %}

View file

@ -25,7 +25,7 @@
{% set tableName = 'plugins' %}
{{ tables.datatable_header(tableName, columns, null, {}) }}
{% for plugin in plugins %}
{% for plugin in plugins|sort((p1, p2) => p1.name|lower <=> p2.name|lower) %}
<tr>
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">
{% if plugin.id != plugin.name %}

View file

@ -6,8 +6,8 @@
{% set tableName = tableName|default('project_details_reporting') %}
{% set tableId = 'project-details-form' %}
{% set view_revenue_tab = project_details is not null and is_granted('budget', project_details.project) %}
{% set view_revenue_user = project_details is not null and is_granted('view_rate_other_timesheet') %}
{% set view_budget = project_details is not null and is_granted('budget', project_details.project) %}
{% set view_revenue = project_details is not null and is_granted('view_rate_other_timesheet') %}
{% set see_users = is_granted('view_other_timesheet') or is_granted('view_other_reporting') %}
{% block stylesheets %}
@ -23,7 +23,7 @@
{% block javascripts %}
{{ parent() }}
{% set options = {'label': 'duration', 'title': 'name', 'legend': 'false'} %}
{% if view_revenue_user and view_revenue_tab %}
{% if view_revenue %}
{% set options = options|merge({'footer': 'rate'}) %}
{% endif %}
{{ charts.doughnut_javascript(options) }}
@ -79,17 +79,17 @@
{% endif %}
{% if hasData %}
{{ _self.project_details(project, project_view, project_details, view_revenue_tab, view_revenue_user, see_users) }}
{{ _self.project_details(project, project_view, project_details, view_budget, view_revenue, see_users) }}
{% set currency = project.customer.currency %}
{%- for yearStat in project_details.years|reverse %}
{% set year = yearStat.year %}
{{ _self.duration_stat(year, year, year, month_names(), yearStat, project_details.getYearActivities(year), project_details.userYears(year), currency, view_revenue_tab, see_users) }}
{{ _self.duration_stat(year, year, year, month_names(), yearStat, project_details.getYearActivities(year), project_details.userYears(year), currency, view_revenue, see_users) }}
{% endfor %}
{% endif %}
{% endblock %}
{% macro duration_stat(id, title, year, labels, yearStat, activities, users, currency, view_revenue_tab, see_users) %}
{% macro duration_stat(id, title, year, labels, yearStat, activities, users, currency, view_revenue, see_users) %}
{% set rates = [] %}
{% set durations = [] %}
{% set chartPrefix = 'chart' ~ id %}
@ -111,11 +111,11 @@
<li><a data-chart="{{ chartPrefix }}User" href="#user-chart-{{ id}}" data-toggle="tab">{{ 'label.user'|trans }}</a></li>
{% endif %}
<li><a data-chart="{{ chartPrefix }}Activity" href="#activity-chart-{{ id}}" data-toggle="tab">{{ 'label.activity'|trans }}</a></li>
{% if view_revenue_tab %}
{% if view_revenue %}
<li><a data-chart="{{ chartPrefix }}Rate" href="#revenue-chart-{{ id }}" data-toggle="tab">{{ 'stats.revenue'|trans }}</a></li>
{% endif %}
<li class="active"><a data-chart="{{ chartPrefix }}Duration" href="#time-chart-{{ id }}" data-toggle="tab">{{ 'stats.workingTime'|trans }}</a></li>
<li class="pull-left header">{{ title }} {{ widgets.label(yearStat.duration|duration, 'gray') }} {{ widgets.label(yearStat.rate|money(currency), 'gray') }}</small></li>
<li class="pull-left header">{{ title }} {{ widgets.label(yearStat.duration|duration, 'gray') }}{% if view_revenue %} {{ widgets.label(yearStat.rate|money(currency), 'gray') }}{% endif %}</small></li>
</ul>
<div class="tab-content no-padding">
<div class="chart tab-pane active" id="time-chart-{{ id }}">
@ -125,7 +125,7 @@
</div>
</div>
</div>
{% if view_revenue_tab %}
{% if view_revenue %}
<div class="chart tab-pane" id="revenue-chart-{{ id }}">
<div class="row">
<div class="col-xs-12">
@ -135,7 +135,7 @@
</div>
{% endif %}
<div class="chart tab-pane" id="activity-chart-{{ id }}">
{{ _self.activity_tab(activities, yearStat.duration, currency, chartPrefix, view_revenue_tab) }}
{{ _self.activity_tab(activities, yearStat.duration, currency, chartPrefix, view_revenue) }}
</div>
{% if see_users %}
<div class="chart tab-pane" id="user-chart-{{ id }}">
@ -208,9 +208,9 @@
totalDuration = int
currency = string
chartPrefix = string
view_revenue_tab = boolean
view_revenue = boolean
#}
{% macro activity_tab(activities, totalDuration, currency, chartPrefix, view_revenue_tab) %}
{% macro activity_tab(activities, totalDuration, currency, chartPrefix, view_revenue) %}
{% set dataset = [] %}
{% set labels = [] %}
<div class="row">
@ -225,7 +225,7 @@
<tr>
<td>{{ widgets.label_activity(stat.activity, {'inherit': false, 'random': true}) }}</td>
<td class="text-nowrap text-right">{{ stat.duration|duration }}</td>
{% if view_revenue_tab %}
{% if view_revenue %}
<td class="text-nowrap text-right">{{ stat.rate|money(currency) }}</td>
{% endif %}
<td class="text-nowrap text-right">{{ percentage|number_format(1) }} %</td>
@ -245,7 +245,7 @@
project_view = ProjectViewModel
project_details = ProjectDetailsModel
#}
{% macro project_details(project, project_view, project_details, view_revenue_tab, view_revenue_user, see_users) %}
{% macro project_details(project, project_view, project_details, view_budget, view_revenue, see_users) %}
{% set activities = project_details.activities %}
{% set years = project_details.years %}
{% import "macros/progressbar.html.twig" as progress %}
@ -271,7 +271,7 @@
{% if activities is not empty %}
<li><a data-chart="{{ chartPrefix }}Activity" href="#activity-chart" data-toggle="tab">{{ 'label.activity'|trans }}</a></li>
{% endif %}
{% if view_revenue_tab and project_view.rateTotal > 0 %}
{% if view_revenue and project_view.rateTotal > 0 %}
<li><a data-chart="{{ chartPrefix }}Rate" href="#revenue-chart" data-toggle="tab">{{ 'stats.revenue'|trans }}</a></li>
{% endif %}
{% if project_view.durationTotal > 0 %}
@ -301,7 +301,7 @@
</th>
<td>{{ project_view.durationTotal|duration }}</td>
</tr>
{% if view_revenue_tab %}
{% if view_revenue %}
<tr>
<th class="w-min">
{{ 'stats.amountTotal'|trans }}
@ -352,7 +352,7 @@
</table>
</div>
</div>
{% if view_revenue_tab and (project.timeBudget > 0 or project.budget > 0) %}
{% if view_budget and (project.timeBudget > 0 or project.budget > 0) %}
{% set budgetStats = project_details.budgetStatisticModel %}
<div class="row">
<div class="col-xs-12">
@ -389,7 +389,7 @@
{% endif %}
</div>
{% if view_revenue_tab and project_view.rateTotal > 0 %}
{% if view_revenue and project_view.rateTotal > 0 %}
<div class="chart tab-pane" id="revenue-chart">
{{ charts.bar_chart(chartPrefix ~ 'Rate', labels, [rates], {'height': '300px', 'renderEvent': 'render.' ~ chartPrefix ~ 'Rate'}) }}
</div>
@ -401,7 +401,7 @@
{% endif %}
{% if activities is not empty %}
<div class="chart tab-pane" id="activity-chart">
{{ _self.activity_tab(activities, project_view.durationTotal, project.customer.currency, chartPrefix, view_revenue_tab) }}
{{ _self.activity_tab(activities, project_view.durationTotal, project.customer.currency, chartPrefix, view_revenue) }}
</div>
{% endif %}
{% if see_users and project_details.userStats|length > 0 %}
@ -430,7 +430,7 @@
<td class="text-nowrap text-right">
{{ userStat.duration|duration }}
</td>
{% if view_revenue_user %}
{% if view_revenue %}
<td class="text-nowrap text-right">
{{ userStat.rate|money(currency) }}
</td>
@ -443,7 +443,7 @@
<tr>
<td></td>
<th class="text-nowrap text-right total">{{ totalDuration|duration }}</th>
{% if view_revenue_user %}
{% if view_revenue %}
<th class="text-nowrap text-right total">{{ rateTotal|money(currency) }}</th>
{% endif %}
<td></td>

View file

@ -170,6 +170,13 @@ class TeamControllerTest extends APIControllerBaseTest
$this->assertNotEmpty($result['id']);
self::assertCount(3, $result['users']);
$this->request($client, '/api/teams/' . $updateId);
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result);
self::assertApiResponseTypeStructure('TeamEntity', $result);
self::assertCount(3, $result['users']);
self::assertFalse($result['members'][1]['teamlead']);
self::assertEquals(1, $result['members'][1]['user']['id']);
self::assertEquals('clara_customer', $result['members'][1]['user']['username']);

View file

@ -31,6 +31,7 @@ class ActivateUserCommandTest extends KernelTestCase
protected function setUp(): void
{
parent::setUp();
$kernel = self::bootKernel();
$this->application = new Application($kernel);
$container = self::$kernel->getContainer();

View file

@ -31,6 +31,7 @@ class ChangePasswordCommandTest extends KernelTestCase
protected function setUp(): void
{
parent::setUp();
$kernel = self::bootKernel();
$this->application = new Application($kernel);
$container = self::$kernel->getContainer();

View file

@ -30,6 +30,7 @@ class CreateUserCommandTest extends KernelTestCase
protected function setUp(): void
{
parent::setUp();
$kernel = self::bootKernel();
$this->application = new Application($kernel);
$container = self::$kernel->getContainer();

View file

@ -31,6 +31,7 @@ class DeactivateUserCommandTest extends KernelTestCase
protected function setUp(): void
{
parent::setUp();
$kernel = self::bootKernel();
$this->application = new Application($kernel);
$container = self::$kernel->getContainer();

View file

@ -32,6 +32,7 @@ class DemoteUserCommandTest extends KernelTestCase
protected function setUp(): void
{
parent::setUp();
$kernel = self::bootKernel();
$this->application = new Application($kernel);
$container = self::$kernel->getContainer();

View file

@ -27,6 +27,7 @@ class ImportCustomerCommandTest extends KernelTestCase
protected function setUp(): void
{
parent::setUp();
$kernel = self::bootKernel();
$this->application = new Application($kernel);
$container = self::$kernel->getContainer();

View file

@ -29,6 +29,7 @@ class ImportProjectCommandTest extends KernelTestCase
protected function setUp(): void
{
parent::setUp();
$kernel = self::bootKernel();
$this->application = new Application($kernel);
$container = self::$kernel->getContainer();

View file

@ -34,6 +34,7 @@ class ImportTimesheetCommandTest extends KernelTestCase
protected function setUp(): void
{
parent::setUp();
$kernel = self::bootKernel();
$this->application = new Application($kernel);

View file

@ -26,6 +26,7 @@ class InstallCommandTest extends KernelTestCase
protected function setUp(): void
{
parent::setUp();
$kernel = self::bootKernel();
$this->application = new Application($kernel);
$container = self::$kernel->getContainer();

View file

@ -61,6 +61,7 @@ class InvoiceCreateCommandTest extends KernelTestCase
protected function setUp(): void
{
parent::setUp();
$this->clearInvoiceFiles();
$kernel = self::bootKernel();
$this->application = new Application($kernel);

View file

@ -29,6 +29,7 @@ class KimaiImporterCommandTest extends KernelTestCase
protected function setUp(): void
{
parent::setUp();
$kernel = self::bootKernel();
$this->application = new Application($kernel);

View file

@ -32,6 +32,7 @@ class PromoteUserCommandTest extends KernelTestCase
protected function setUp(): void
{
parent::setUp();
$kernel = self::bootKernel();
$this->application = new Application($kernel);
$container = self::$kernel->getContainer();

View file

@ -26,6 +26,7 @@ class ReloadCommandTest extends KernelTestCase
protected function setUp(): void
{
parent::setUp();
$kernel = self::bootKernel();
$this->application = new Application($kernel);
$this->application->add(new ReloadCommand());

View file

@ -28,6 +28,7 @@ class VersionCommandTest extends KernelTestCase
protected function setUp(): void
{
parent::setUp();
$kernel = self::bootKernel();
$this->application = new Application($kernel);

View file

@ -92,6 +92,9 @@ class TeamTest extends TestCase
self::assertCount(0, $sut->getMembers());
self::assertFalse($sut->isTeamlead($user));
$member = new TeamMember();
$member->setUser($user);
$sut->addMember($member);
$member->setTeamlead(true);
self::assertTrue($sut->isTeamlead($user));

View file

@ -46,10 +46,10 @@ class UserPreferenceTest extends TestCase
$sut->setType(IntegerType::class);
self::assertSame(1, $sut->getValue());
$sut->setType(YesNoType::class);
self::assertSame(true, $sut->getValue());
self::assertTrue($sut->getValue());
$sut->setValue('0');
$sut->setType(CheckboxType::class);
self::assertSame(false, $sut->getValue());
self::assertFalse($sut->getValue());
}
public function testGetLabelWithLabelOption()

View file

@ -449,6 +449,9 @@ class UserTest extends TestCase
self::assertFalse($sut->isTeamleadOf($team));
$member = new TeamMember();
$member->setTeam($team);
$sut->addMembership($member);
self::assertCount(1, $sut->getMemberships());
self::assertFalse($sut->isTeamleadOf($team));

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\Invoice;
use App\Event\InvoiceDeleteEvent;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Event\InvoiceDeleteEvent
*/
class InvoiceDeleteEventTest extends TestCase
{
public function testDefaultValues()
{
$invoice = new Invoice();
$sut = new InvoiceDeleteEvent($invoice);
self::assertSame($invoice, $sut->getInvoice());
}
}

View file

@ -31,6 +31,7 @@ abstract class AbstractRepositoryTest extends KernelTestCase
*/
protected function setUp(): void
{
parent::setUp();
$kernel = self::bootKernel();
$this->entityManager = $kernel->getContainer()

View file

@ -0,0 +1,19 @@
<?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.
*/
use App\Kernel;
use Symfony\Component\Dotenv\Dotenv;
require __DIR__ . '/../vendor/autoload.php';
(new Dotenv(false))->loadEnv(dirname(__DIR__) . '/.env');
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$kernel->boot();
return $kernel->getContainer()->get('doctrine')->getManager();

View file

@ -1,7 +1,10 @@
includes:
- %rootDir%/../phpstan-symfony/extension.neon
- %rootDir%/../phpstan-symfony/rules.neon
- %rootDir%/../phpstan-doctrine/extension.neon
- %rootDir%/../phpstan-doctrine/rules.neon
- %rootDir%/../phpstan-phpunit/extension.neon
- %rootDir%/../phpstan-phpunit/rules.neon
parameters:
tmpDir: %rootDir%/../../../var/cache/phpstan
@ -9,4 +12,6 @@ parameters:
- '#Call to static method PHPUnit\\Framework\\Assert::assertSame\(\) with App\\Entity\\[a-zA-Z0-9]+ and null will always evaluate to false.#'
excludePaths:
- %rootDir%/../../../tests/Ldap/LdapDriverTest.php
inferPrivatePropertyTypeFromConstructor: true
inferPrivatePropertyTypeFromConstructor: true
doctrine:
objectManagerLoader: %rootDir%/../../../tests/phpstan-doctrine.php

View file

@ -28,7 +28,7 @@
</trans-unit>
<trans-unit id="A0nWoXZ" resname="special_thanks">
<source>special_thanks</source>
<target>これらの方々に特に感謝いたします。</target>
<target>に深く感謝します</target>
</trans-unit>
<trans-unit id="L7cff3Q" resname="library_authors">
<source>library_authors</source>

View file

@ -14,6 +14,10 @@
<source>default-internal.pdf.twig</source>
<target>Inkl. interner Kosten</target>
</trans-unit>
<trans-unit id="bTIKMV." resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>Stundennachweis</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -2,18 +2,22 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="en" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>default.pdf.twig</source>
<target>Standard</target>
<trans-unit id="IbERy.5" resname="default.pdf.twig">
<source>Default</source>
<target>Default</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
<trans-unit id="Xvh5B5U" resname="default-budget.pdf.twig">
<source>With remaining budget</source>
<target>With remaining budget</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>default-internal.pdf.twig</source>
<trans-unit id="rok3rBO" resname="default-internal.pdf.twig">
<source>With internal rates</source>
<target>With internal rates</target>
</trans-unit>
<trans-unit id="bTIKMV." resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>Timesheet</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="he" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>default.pdf.twig</source>
<target>סטנדרטי</target>
<source>Default</source>
<target>ברירת מחדל</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
@ -14,6 +14,10 @@
<source>default-internal.pdf.twig</source>
<target>עם תעריפים פנימיים</target>
</trans-unit>
<trans-unit id="bTIKMV." resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>גיליון שעות</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="pt-BR" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>default.pdf.twig</source>
<source>Default</source>
<target>Padrão</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
@ -14,6 +14,10 @@
<source>default-internal.pdf.twig</source>
<target>Com as taxas internas</target>
</trans-unit>
<trans-unit id="bTIKMV." resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>Planilha de horários</target>
</trans-unit>
</body>
</file>
</xliff>

10
translations/export.ro.xlf Executable file → Normal file
View file

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="ro" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>default.pdf.twig</source>
<target>Standard</target>
<source>Default</source>
<target>Implicit</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
@ -14,6 +14,10 @@
<source>default-internal.pdf.twig</source>
<target>Cu tarifele interne</target>
</trans-unit>
<trans-unit id="bTIKMV." resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>Fișă de pontaj</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -3,8 +3,8 @@
<file source-language="en" target-language="tr" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>default.pdf.twig</source>
<target>Standart</target>
<source>Default</source>
<target>Öntanımlı</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
@ -14,6 +14,10 @@
<source>default-internal.pdf.twig</source>
<target>Dahili ücretlerle</target>
</trans-unit>
<trans-unit id="bTIKMV." resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>Zaman çizelgesi</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="zh-CN" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>default.pdf.twig</source>
<target>标准</target>
<source>Default</source>
<target>默认</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fa" datatype="plaintext" original="messages.en.xlf">
<body>
@ -427,6 +427,141 @@
<source>label.searchTerm</source>
<target>عبارت جستجو</target>
</trans-unit>
<trans-unit id="Zpj9UB4" resname="label.teamlead">
<source>label.teamlead</source>
<target>رهبری تیم</target>
</trans-unit>
<trans-unit id="trDJK9W" resname="label.begin">
<source>label.begin</source>
<target>از</target>
</trans-unit>
<trans-unit id="R9typUt" resname="label.end">
<source>label.end</source>
<target>به</target>
</trans-unit>
<trans-unit id="egaNXjp" resname="label.daterange">
<source>label.daterange</source>
<target>محدوده زمانی</target>
</trans-unit>
<trans-unit id="YTSS_Q1" resname="label.color">
<source>label.color</source>
<target>رنگ</target>
</trans-unit>
<trans-unit id="GzWKgBk" resname="error.directory_missing">
<source>error.directory_missing</source>
<target>موقعیت "%dir%" وجود ندارد و قابل ساختن نیست.</target>
</trans-unit>
<trans-unit id="C2TGff0" resname="error.directory_protected">
<source>error.directory_protected</source>
<target>موقعیت "%dir%" در برابر write حفاظت شده است.</target>
</trans-unit>
<trans-unit id="jdbtx6z" resname="action.add">
<source>action.add</source>
<target>افزودن</target>
</trans-unit>
<trans-unit id="b43sCwO" resname="help.fixedRate">
<source>help.fixedRate</source>
<target>هربار سابقه مقدار معینی میگیرد، صرف نظر از مدت آن</target>
</trans-unit>
<trans-unit id="EoouxGX" resname="label.replaceTags">
<source>label.replaceTags</source>
<target>جایگزینی برچسب ها</target>
</trans-unit>
<trans-unit id="BxkmaKz" resname="label.appendTags">
<source>label.appendTags</source>
<target>پیوست تگ ها</target>
</trans-unit>
<trans-unit id="1_wnK76" resname="profile.title">
<source>profile.title</source>
<target>نمایه کاربر</target>
</trans-unit>
<trans-unit id="HWKJZ0T" resname="profile.about_me">
<source>profile.about_me</source>
<target>درباره من</target>
</trans-unit>
<trans-unit id="1c0EQaz" resname="profile.registration_date">
<source>profile.registration_date</source>
<target>ثبت شده در</target>
</trans-unit>
<trans-unit id="A6TLLQa" resname="profile.password">
<source>profile.password</source>
<target>رمز</target>
</trans-unit>
<trans-unit id="Z34ZpjK" resname="profile.api-token">
<source>profile.api-token</source>
<target>رابط برنامه نویسی اپلیکیشن</target>
</trans-unit>
<trans-unit id="ygtTz8." resname="profile.roles">
<source>profile.roles</source>
<target>نقش ها</target>
</trans-unit>
<trans-unit id="DZ7XGML" resname="profile.teams">
<source>profile.teams</source>
<target>تیم ها</target>
</trans-unit>
<trans-unit id="MQKiG33" resname="profile.preferences">
<source>profile.preferences</source>
<target>الویت ها</target>
</trans-unit>
<trans-unit id="iqQsgIW" resname="label.login.initial_view">
<source>label.login.initial_view</source>
<target>نمایه ابتدایی بعد از ورود</target>
</trans-unit>
<trans-unit id="hO7mkmf" resname="label.lastLogin">
<source>label.lastLogin</source>
<target>آخرین ورود</target>
</trans-unit>
<trans-unit id="Eq78QrH" resname="label.theme.collapsed_sidebar">
<source>label.theme.collapsed_sidebar</source>
<target>کوچک کردن نوار کناری سمت چپ</target>
</trans-unit>
<trans-unit id="0sgroZi" resname="label.calendar.initial_view">
<source>label.calendar.initial_view</source>
<target>مشاهده ابتدایی و ساده تقویم</target>
</trans-unit>
<trans-unit id="dwEVXUR" resname="timesheet.edit">
<source>timesheet.edit</source>
<target>ویرایش سابقه.</target>
</trans-unit>
<trans-unit id="UbnqJLn" resname="modal.columns.title">
<source>modal.columns.title</source>
<target>ویرایش پدیداری ستون</target>
</trans-unit>
<trans-unit id="xEYQXPy" resname="profile.settings">
<source>profile.settings</source>
<target>نمایه</target>
</trans-unit>
<trans-unit id="NNKq04n" resname="label.remove_default">
<source>label.remove_default</source>
<target>حذف جستجوی علاقه مندی</target>
</trans-unit>
<trans-unit id=".ndupSK" resname="registration.check_email">
<source>registration.check_email</source>
<target>ایمیلی برای %email% شامل لینک فعالسازی ارسال شد. جهت فعال کردن حساب کاربری خود باید بر روی آن کلیک کنید.</target>
</trans-unit>
<trans-unit id="_cY.wHP" resname="resetting.check_email">
<source>resetting.check_email</source>
<target>‌لینکی به ایمیل شما ارسال شد. جهت بازیابی رمز خود روی آن کلیک کنید. نکته: شما در طی %tokenLifetime% ساعت فقط یکبار می‌توانید رمز جدید درخواست کنید.
اگر ایمیل برای شما ارسال نشد پوشه spam را بررسی کنید یا دوباره تلاش کنید.</target>
</trans-unit>
<trans-unit id="XQ2.pq2" resname="error.too_many_entries">
<source>error.too_many_entries</source>
<target>درخواست قابل انجام نیست.
نتایج زیادی پیدا شدند.</target>
</trans-unit>
<trans-unit id="5gjqxgK" resname="label.fixedRate">
<source>label.fixedRate</source>
<target>نرخ ثابت</target>
</trans-unit>
<trans-unit id="EbfIoLp" resname="modal.columns.description">
<source>modal.columns.description</source>
<target>ذخیره ستون های علامت گذاری نشده مخفی خواهد بود و این تنظیمات در کوکی مرورگر ذخیره می‌شود.
اگر کوکی ها را پاک کنید تنظیمات به حالت اولیه بازمیگردند.</target>
</trans-unit>
<trans-unit id="Fm.kwVn" resname="profile.first_entry">
<source>profile.first_entry</source>
<target>کار میکند از تاریخ</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -975,6 +975,70 @@
<source>label.hours_24</source>
<target>24 tímar</target>
</trans-unit>
<trans-unit id="5gjqxgK" resname="label.fixedRate">
<source>label.fixedRate</source>
<target>Fastur kostnáður</target>
</trans-unit>
<trans-unit id="Dgmh_mv" resname="label.vat_id">
<source>label.vat_id</source>
<target>V-tal</target>
</trans-unit>
<trans-unit id="F6Jmro7" resname="export.title">
<source>export.title</source>
<target>Útflyt</target>
</trans-unit>
<trans-unit id="pnGDFmf" resname="label.type">
<source>label.type</source>
<target>Slag</target>
</trans-unit>
<trans-unit id="_cY.wHP" resname="resetting.check_email">
<source>resetting.check_email</source>
<target>Ein teldupostur er sendur. Telduposturin inniheldur eina leinkju, ið tú skalt trýst á, fyri at endurseta títt loyniorð. GG: Tú kanst einans biða um eitt nýtt loyniorð einaferð hvønn %tokenLifetime% tíma. Um tú ikki móttekur ein teldupost, kannað so tína fras mappu ella royn umaftur.</target>
</trans-unit>
<trans-unit id="Uvo1CbP" resname="status.pending">
<source>status.pending</source>
<target>Bíðistøðu</target>
</trans-unit>
<trans-unit id="GzWKgBk" resname="error.directory_missing">
<source>error.directory_missing</source>
<target>Mappan "%dir%" finninst ikki, og tað bar ikki til at skapa hana.</target>
</trans-unit>
<trans-unit id="C2TGff0" resname="error.directory_protected">
<source>error.directory_protected</source>
<target>Mappan "%dir%" skrivivard.</target>
</trans-unit>
<trans-unit id="0sgroZi" resname="label.calendar.initial_view">
<source>label.calendar.initial_view</source>
<target>Byrjanarvísan fyri kalendara</target>
</trans-unit>
<trans-unit id="iqQsgIW" resname="label.login.initial_view">
<source>label.login.initial_view</source>
<target>Byrjanarvísan eftir innritan</target>
</trans-unit>
<trans-unit id="5R.QsZ3" resname="theme.update_browser_title">
<source>theme.update_browser_title</source>
<target>navnabót kagara dagførd</target>
</trans-unit>
<trans-unit id="tSlqVK2" resname="label.timesheet.export_decimal">
<source>label.timesheet.export_decimal</source>
<target>Nýt kommatals-tíðarlongd í útflutningi</target>
</trans-unit>
<trans-unit id="7J7G3Vr" resname="label.mark_as_exported">
<source>label.mark_as_exported</source>
<target>Merk sum útflutt</target>
</trans-unit>
<trans-unit id="xN7MfSA" resname="stats.percentUsed_month">
<source>stats.percentUsed_month</source>
<target>%percent%% brúkt hendan mánaðin</target>
</trans-unit>
<trans-unit id="vWJPpYU" resname="stats.percentUsedLeft_month">
<source>stats.percentUsedLeft_month</source>
<target>%percent%% brúkt (%left% er enn opið hendan mánaðin)</target>
</trans-unit>
<trans-unit id="XQ2.pq2" resname="error.too_many_entries">
<source>error.too_many_entries</source>
<target>Umbønin kundi ikki fullfíggjast. Ov nógv úrslit vóru funnin.</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -817,6 +817,14 @@
<source>label.globalsOnly</source>
<target>グローバルオンリー</target>
</trans-unit>
<trans-unit id=".0CFrRV" resname="upload">
<source>upload</source>
<target>アップロード</target>
</trans-unit>
<trans-unit id="JBkykGe" resname="search">
<source>search</source>
<target>検索</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -1253,6 +1253,14 @@
<source>label.budgetType_month</source>
<target>월별</target>
</trans-unit>
<trans-unit id="C2TGff0" resname="error.directory_protected">
<source>error.directory_protected</source>
<target>Directory "%dir%" is write protected.</target>
</trans-unit>
<trans-unit id="GzWKgBk" resname="error.directory_missing">
<source>error.directory_missing</source>
<target>Directory "%dir%" is not existing and could not be created.</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -943,7 +943,7 @@
</trans-unit>
<trans-unit id="JIKNAYP" resname="stats.workingTimeMonth">
<source>stats.workingTimeMonth</source>
<target state="translated">%month% %year%</target>
<target state="translated">月,年,%month% %year%</target>
</trans-unit>
<trans-unit id="4kQGReV" resname="label.project_end">
<source>label.project_end</source>
@ -1237,6 +1237,38 @@
<source>add_user.label</source>
<target>添加用户</target>
</trans-unit>
<trans-unit id="tSlqVK2" resname="label.timesheet.export_decimal">
<source>label.timesheet.export_decimal</source>
<target>在导出中使用十进制持续时间</target>
</trans-unit>
<trans-unit id="Wq.h4nD" resname="label.includeBudgetType_month">
<source>label.includeBudgetType_month</source>
<target>显示带有“每月”预算的条目</target>
</trans-unit>
<trans-unit id="nVulc7." resname="label.includeBudgetType_full">
<source>label.includeBudgetType_full</source>
<target>显示包含“生命周期”预算的条目</target>
</trans-unit>
<trans-unit id="tvV0NhL" resname="delete_warning.short_stats">
<source>delete_warning.short_stats</source>
<target>当前存在%records%时间记录,总计持续时间为%duration%。</target>
</trans-unit>
<trans-unit id="AJ6dwR." resname="label.last_record_before">
<source>label.last_record_before</source>
<target>以后没有时间预订</target>
</trans-unit>
<trans-unit id="9Rhadz6" resname="label.budgetType_full">
<source>label.budgetType_full</source>
<target>生命周期</target>
</trans-unit>
<trans-unit id="yNWIi5U" resname="default_value_new">
<source>default_value_new</source>
<target>新条目的默认值</target>
</trans-unit>
<trans-unit id="nlwMf5w" resname="invoice_document.max_reached">
<source>invoice_document.max_reached</source>
<target>已达到%max%发票单据的最大金额。删除一个后,可以添加更多。</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -294,6 +294,10 @@
<source>label.theme.avatar_url</source>
<target>Permitir a utilização de URLs para as imagens do avatar</target>
</trans-unit>
<trans-unit id="zeOPuh6" resname="label.quick_entry.recent_activities">
<source>label.quick_entry.recent_activities</source>
<target>Quantidade de entradas retomadas das semanas anteriores</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -258,6 +258,18 @@
<source>label.theme.tags_create</source>
<target>标签:使用自动完成搜索并允许创建标签</target>
</trans-unit>
<trans-unit id="zeOPuh6" resname="label.quick_entry.recent_activities">
<source>label.quick_entry.recent_activities</source>
<target>从前几周接收的参赛作品数量</target>
</trans-unit>
<trans-unit id="SCkhv_z" resname="label.theme.avatar_url">
<source>label.theme.avatar_url</source>
<target>允许对化身图像使用URL</target>
</trans-unit>
<trans-unit id="40i4Xlr" resname="label.timesheet.duration_increment">
<source>label.timesheet.duration_increment</source>
<target>持续时间的分钟选择</target>
</trans-unit>
</body>
</file>
</xliff>