prepare release 1.15 (#2707)
* bump version * fix invisible class on labels * fail safe removal of foreign key * check if optional form field exists before accessing it * silently ignore stopped timesheets * prevent colliding parameter names * make sure decimal duration is always rendered with two decimals * simplify translation * fix #2751 ANSI_QUOTES * rename composer task * fix billable statistic rates * added missing translation for export
This commit is contained in:
2
.github/workflows/testing.yaml
vendored
2
.github/workflows/testing.yaml
vendored
@@ -51,7 +51,7 @@ jobs:
|
|||||||
run: vendor/bin/phpstan analyse tests -c tests/phpstan.neon --level=5 --no-progress --error-format=checkstyle | cs2pr
|
run: vendor/bin/phpstan analyse tests -c tests/phpstan.neon --level=5 --no-progress --error-format=checkstyle | cs2pr
|
||||||
|
|
||||||
- name: Lint codebase
|
- name: Lint codebase
|
||||||
run: composer kimai:code-lint
|
run: composer linting
|
||||||
|
|
||||||
- name: Install LDAP package
|
- name: Install LDAP package
|
||||||
run: composer require laminas/laminas-ldap
|
run: composer require laminas/laminas-ldap
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ As announced in the [README](README.md) I only support the latest available rele
|
|||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| ------- | ------------------ |
|
| ------- | ------------------ |
|
||||||
| master | :white_check_mark: |
|
| master | :white_check_mark: |
|
||||||
| 1.14 | :white_check_mark: |
|
| 1.15 | :white_check_mark: |
|
||||||
| < 1.14 | :x: |
|
| < 1.15 | :x: |
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
|||||||
@@ -146,7 +146,7 @@
|
|||||||
"kimai:pre-commit": [
|
"kimai:pre-commit": [
|
||||||
"@kimai:codestyle",
|
"@kimai:codestyle",
|
||||||
"@kimai:phpstan",
|
"@kimai:phpstan",
|
||||||
"@kimai:code-lint",
|
"@linting",
|
||||||
"@kimai:tests-unit"
|
"@kimai:tests-unit"
|
||||||
],
|
],
|
||||||
"kimai:code-check": "@code-check",
|
"kimai:code-check": "@code-check",
|
||||||
@@ -154,7 +154,8 @@
|
|||||||
"@kimai:pre-commit",
|
"@kimai:pre-commit",
|
||||||
"@kimai:tests-integration"
|
"@kimai:tests-integration"
|
||||||
],
|
],
|
||||||
"kimai:code-lint": [
|
"kimai:code-lint": "@linting",
|
||||||
|
"linting": [
|
||||||
"composer validate --strict",
|
"composer validate --strict",
|
||||||
"bin/console lint:container",
|
"bin/console lint:container",
|
||||||
"bin/console lint:yaml config --parse-tags",
|
"bin/console lint:yaml config --parse-tags",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class Constants
|
|||||||
/**
|
/**
|
||||||
* The current release status, either "stable" or "dev"
|
* The current release status, either "stable" or "dev"
|
||||||
*/
|
*/
|
||||||
public const STATUS = 'dev';
|
public const STATUS = 'stable';
|
||||||
/**
|
/**
|
||||||
* The software name
|
* The software name
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -203,12 +203,12 @@ final class PermissionController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @Route(path="/roles/{id}/delete/{token}", name="admin_user_role_delete", methods={"GET", "POST"})
|
* @Route(path="/roles/{id}/delete/{csrfToken}", name="admin_user_role_delete", methods={"GET", "POST"})
|
||||||
* @Security("is_granted('role_permissions')")
|
* @Security("is_granted('role_permissions')")
|
||||||
*/
|
*/
|
||||||
public function deleteRole(Role $role, string $token, UserRepository $userRepository, CsrfTokenManagerInterface $csrfTokenManager): Response
|
public function deleteRole(Role $role, string $csrfToken, UserRepository $userRepository, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||||
{
|
{
|
||||||
if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $token)) {
|
if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $csrfToken)) {
|
||||||
$this->flashUpdateException(new \Exception('Invalid CSRF token'));
|
$this->flashUpdateException(new \Exception('Invalid CSRF token'));
|
||||||
|
|
||||||
return $this->redirectToRoute('admin_user_permissions');
|
return $this->redirectToRoute('admin_user_permissions');
|
||||||
@@ -235,12 +235,12 @@ final class PermissionController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @Route(path="/roles/{id}/{name}/{value}/{token}", name="admin_user_permission_save", methods={"POST"})
|
* @Route(path="/roles/{id}/{name}/{value}/{csrfToken}", name="admin_user_permission_save", methods={"POST"})
|
||||||
* @Security("is_granted('role_permissions')")
|
* @Security("is_granted('role_permissions')")
|
||||||
*/
|
*/
|
||||||
public function savePermission(Role $role, string $name, bool $value, string $token, RolePermissionRepository $rolePermissionRepository, CsrfTokenManagerInterface $csrfTokenManager): Response
|
public function savePermission(Role $role, string $name, bool $value, string $csrfToken, RolePermissionRepository $rolePermissionRepository, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||||
{
|
{
|
||||||
if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $token)) {
|
if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $csrfToken)) {
|
||||||
throw new BadRequestHttpException('Invalid CSRF token');
|
throw new BadRequestHttpException('Invalid CSRF token');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ final class UserController extends AbstractController
|
|||||||
|
|
||||||
$this->flashSuccess('action.update.success');
|
$this->flashSuccess('action.update.success');
|
||||||
|
|
||||||
if ($editForm->get('create_more')->getData() !== true) {
|
if ($editForm->has('create_more') && $editForm->get('create_more')->getData() !== true) {
|
||||||
return $this->redirectToRoute('user_profile_edit', ['username' => $user->getUsername()]);
|
return $this->redirectToRoute('user_profile_edit', ['username' => $user->getUsername()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,11 +52,11 @@ final class Version20180715160326 extends AbstractMigration
|
|||||||
$this->addSql('UPDATE ' . $users . ' set username_canonical = username');
|
$this->addSql('UPDATE ' . $users . ' set username_canonical = username');
|
||||||
$this->addSql('UPDATE ' . $users . ' set email_canonical = email');
|
$this->addSql('UPDATE ' . $users . ' set email_canonical = email');
|
||||||
|
|
||||||
$this->addSql('UPDATE ' . $users . ' SET roles = \'a:1:{i:0;s:16:"ROLE_SUPER_ADMIN";}\' WHERE roles LIKE "%ROLE_SUPER_ADMIN%"');
|
$this->addSql('UPDATE ' . $users . ' SET roles = \'a:1:{i:0;s:16:"ROLE_SUPER_ADMIN";}\' WHERE roles LIKE \'%ROLE_SUPER_ADMIN%\'');
|
||||||
$this->addSql('UPDATE ' . $users . ' SET roles = \'a:1:{i:0;s:10:"ROLE_ADMIN";}\' WHERE roles LIKE "%ROLE_ADMIN%"');
|
$this->addSql('UPDATE ' . $users . ' SET roles = \'a:1:{i:0;s:10:"ROLE_ADMIN";}\' WHERE roles LIKE \'%ROLE_ADMIN%\'');
|
||||||
$this->addSql('UPDATE ' . $users . ' SET roles = \'a:1:{i:0;s:13:"ROLE_TEAMLEAD";}\' WHERE roles LIKE "%ROLE_TEAMLEAD%"');
|
$this->addSql('UPDATE ' . $users . ' SET roles = \'a:1:{i:0;s:13:"ROLE_TEAMLEAD";}\' WHERE roles LIKE \'%ROLE_TEAMLEAD%\'');
|
||||||
$this->addSql('UPDATE ' . $users . ' SET roles = \'a:0:{}\' WHERE roles LIKE "%ROLE_USER%"');
|
$this->addSql('UPDATE ' . $users . ' SET roles = \'a:0:{}\' WHERE roles LIKE \'%ROLE_USER%\'');
|
||||||
$this->addSql('UPDATE ' . $users . ' SET roles = \'a:1:{i:0;s:13:"ROLE_CUSTOMER";}\' WHERE roles LIKE "%ROLE_CUSTOMER%"');
|
$this->addSql('UPDATE ' . $users . ' SET roles = \'a:1:{i:0;s:13:"ROLE_CUSTOMER";}\' WHERE roles LIKE \'%ROLE_CUSTOMER%\'');
|
||||||
|
|
||||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCE92FC23A8 ON ' . $users . ' (username_canonical)');
|
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCE92FC23A8 ON ' . $users . ' (username_canonical)');
|
||||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCEA0D96FBF ON ' . $users . ' (email_canonical)');
|
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCEA0D96FBF ON ' . $users . ' (email_canonical)');
|
||||||
@@ -81,11 +81,11 @@ final class Version20180715160326 extends AbstractMigration
|
|||||||
|
|
||||||
$this->addSql('ALTER TABLE ' . $users . ' CHANGE username name VARCHAR(60) NOT NULL COLLATE utf8mb4_unicode_ci, CHANGE email mail VARCHAR(160) NOT NULL COLLATE utf8mb4_unicode_ci, DROP username_canonical, DROP email_canonical, DROP salt, DROP last_login, DROP confirmation_token, DROP password_requested_at, CHANGE password password VARCHAR(254) DEFAULT NULL COLLATE utf8mb4_unicode_ci, CHANGE roles roles LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', CHANGE alias alias VARCHAR(60) DEFAULT NULL COLLATE utf8mb4_unicode_ci, CHANGE registration_date registration_date DATETIME DEFAULT NULL, CHANGE title title VARCHAR(50) DEFAULT NULL COLLATE utf8mb4_unicode_ci, CHANGE avatar avatar VARCHAR(255) DEFAULT NULL COLLATE utf8mb4_unicode_ci, CHANGE enabled active TINYINT(1) NOT NULL');
|
$this->addSql('ALTER TABLE ' . $users . ' CHANGE username name VARCHAR(60) NOT NULL COLLATE utf8mb4_unicode_ci, CHANGE email mail VARCHAR(160) NOT NULL COLLATE utf8mb4_unicode_ci, DROP username_canonical, DROP email_canonical, DROP salt, DROP last_login, DROP confirmation_token, DROP password_requested_at, CHANGE password password VARCHAR(254) DEFAULT NULL COLLATE utf8mb4_unicode_ci, CHANGE roles roles LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', CHANGE alias alias VARCHAR(60) DEFAULT NULL COLLATE utf8mb4_unicode_ci, CHANGE registration_date registration_date DATETIME DEFAULT NULL, CHANGE title title VARCHAR(50) DEFAULT NULL COLLATE utf8mb4_unicode_ci, CHANGE avatar avatar VARCHAR(255) DEFAULT NULL COLLATE utf8mb4_unicode_ci, CHANGE enabled active TINYINT(1) NOT NULL');
|
||||||
|
|
||||||
$this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_SUPER_ADMIN"]\' WHERE roles LIKE "%ROLE_SUPER_ADMIN%"');
|
$this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_SUPER_ADMIN"]\' WHERE roles LIKE \'%ROLE_SUPER_ADMIN%\'');
|
||||||
$this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_ADMIN"]\' WHERE roles LIKE "%ROLE_ADMIN%"');
|
$this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_ADMIN"]\' WHERE roles LIKE \'%ROLE_ADMIN%\'');
|
||||||
$this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_TEAMLEAD"]\' WHERE roles LIKE "%ROLE_TEAMLEAD%"');
|
$this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_TEAMLEAD"]\' WHERE roles LIKE \'%ROLE_TEAMLEAD%\'');
|
||||||
$this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_USER"]\' WHERE roles LIKE "%ROLE_USER%"');
|
$this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_USER"]\' WHERE roles LIKE \'%ROLE_USER%\'');
|
||||||
$this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_CUSTOMER"]\' WHERE roles LIKE "%ROLE_CUSTOMER%"');
|
$this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_CUSTOMER"]\' WHERE roles LIKE \'%ROLE_CUSTOMER%\'');
|
||||||
|
|
||||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCE5E237E06 ON ' . $users . ' (name)');
|
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCE5E237E06 ON ' . $users . ' (name)');
|
||||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCE5126AC48 ON ' . $users . ' (mail)');
|
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCE5126AC48 ON ' . $users . ' (mail)');
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ final class Version20190201150324 extends AbstractMigration
|
|||||||
|
|
||||||
$this->addSql('ALTER TABLE kimai2_timesheet ADD timezone VARCHAR(64) NOT NULL');
|
$this->addSql('ALTER TABLE kimai2_timesheet ADD timezone VARCHAR(64) NOT NULL');
|
||||||
|
|
||||||
$this->addSql('UPDATE kimai2_timesheet SET timezone = "' . $timezone . '"');
|
$this->addSql("UPDATE kimai2_timesheet SET timezone = '" . $timezone . "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
public function down(Schema $schema): void
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ final class Version20210802174320 extends AbstractMigration
|
|||||||
public function up(Schema $schema): void
|
public function up(Schema $schema): void
|
||||||
{
|
{
|
||||||
$teams = $schema->getTable('kimai2_teams');
|
$teams = $schema->getTable('kimai2_teams');
|
||||||
$teams->removeForeignKey('FK_3BEDDC7F8F7DE5D7');
|
// @see https://github.com/kevinpapst/kimai2/issues/2706
|
||||||
|
if ($teams->hasForeignKey('FK_3BEDDC7F8F7DE5D7')) {
|
||||||
|
$teams->removeForeignKey('FK_3BEDDC7F8F7DE5D7');
|
||||||
|
}
|
||||||
$teams->dropColumn('teamlead_id');
|
$teams->dropColumn('teamlead_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,29 +16,16 @@ use DateTime;
|
|||||||
*/
|
*/
|
||||||
class TimesheetStatistic
|
class TimesheetStatistic
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* @var int
|
|
||||||
*/
|
|
||||||
protected $durationThisMonth = 0;
|
|
||||||
/**
|
|
||||||
* @var int
|
|
||||||
*/
|
|
||||||
protected $durationTotal = 0;
|
|
||||||
/**
|
|
||||||
* @var float
|
|
||||||
*/
|
|
||||||
protected $amountThisMonth = 0;
|
|
||||||
/**
|
|
||||||
* @var float
|
|
||||||
*/
|
|
||||||
protected $amountTotal = 0;
|
|
||||||
/**
|
/**
|
||||||
* @var \DateTime|null
|
* @var \DateTime|null
|
||||||
*/
|
*/
|
||||||
protected $firstEntry;
|
protected $firstEntry;
|
||||||
/**
|
protected $durationThisMonth = 0;
|
||||||
* @var int
|
protected $durationTotal = 0;
|
||||||
*/
|
protected $amountThisMonth = 0.0;
|
||||||
|
protected $amountTotal = 0.0;
|
||||||
|
protected $amountThisMonthBillable = 0.0;
|
||||||
|
protected $amountTotalBillable = 0.0;
|
||||||
protected $recordsTotal = 0;
|
protected $recordsTotal = 0;
|
||||||
|
|
||||||
public function getDurationThisMonth(): int
|
public function getDurationThisMonth(): int
|
||||||
@@ -46,12 +33,9 @@ class TimesheetStatistic
|
|||||||
return $this->durationThisMonth;
|
return $this->durationThisMonth;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function setDurationThisMonth(int $durationThisMonth): void
|
||||||
* @param int $durationThisMonth
|
|
||||||
*/
|
|
||||||
public function setDurationThisMonth($durationThisMonth)
|
|
||||||
{
|
{
|
||||||
$this->durationThisMonth = (int) $durationThisMonth;
|
$this->durationThisMonth = $durationThisMonth;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,11 +53,21 @@ class TimesheetStatistic
|
|||||||
*
|
*
|
||||||
* @param float|int $amountTotal
|
* @param float|int $amountTotal
|
||||||
*/
|
*/
|
||||||
public function setAmountTotal($amountTotal)
|
public function setAmountTotal($amountTotal): void
|
||||||
{
|
{
|
||||||
$this->amountTotal = (float) $amountTotal;
|
$this->amountTotal = (float) $amountTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getRateTotalBillable(): float
|
||||||
|
{
|
||||||
|
return $this->amountTotalBillable;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRateTotalBillable(float $amountTotal): void
|
||||||
|
{
|
||||||
|
$this->amountTotalBillable = $amountTotal;
|
||||||
|
}
|
||||||
|
|
||||||
public function getDurationTotal(): int
|
public function getDurationTotal(): int
|
||||||
{
|
{
|
||||||
return $this->durationTotal;
|
return $this->durationTotal;
|
||||||
@@ -82,7 +76,7 @@ class TimesheetStatistic
|
|||||||
/**
|
/**
|
||||||
* @param int $durationTotal
|
* @param int $durationTotal
|
||||||
*/
|
*/
|
||||||
public function setDurationTotal($durationTotal)
|
public function setDurationTotal($durationTotal): void
|
||||||
{
|
{
|
||||||
$this->durationTotal = (int) $durationTotal;
|
$this->durationTotal = (int) $durationTotal;
|
||||||
}
|
}
|
||||||
@@ -102,11 +96,21 @@ class TimesheetStatistic
|
|||||||
*
|
*
|
||||||
* @param float|int $amountThisMonth
|
* @param float|int $amountThisMonth
|
||||||
*/
|
*/
|
||||||
public function setAmountThisMonth($amountThisMonth)
|
public function setAmountThisMonth($amountThisMonth): void
|
||||||
{
|
{
|
||||||
$this->amountThisMonth = (float) $amountThisMonth;
|
$this->amountThisMonth = (float) $amountThisMonth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getRateThisMonthBillable(): float
|
||||||
|
{
|
||||||
|
return $this->amountThisMonthBillable;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRateThisMonthBillable(float $amountThisMonth): void
|
||||||
|
{
|
||||||
|
$this->amountThisMonthBillable = $amountThisMonth;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated since 1.15 use TimesheetStatisticService::findFirstRecordDate() instead, will be removed with 2.0
|
* @deprecated since 1.15 use TimesheetStatisticService::findFirstRecordDate() instead, will be removed with 2.0
|
||||||
*/
|
*/
|
||||||
@@ -115,30 +119,18 @@ class TimesheetStatistic
|
|||||||
return $this->firstEntry;
|
return $this->firstEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function setFirstEntry(DateTime $firstEntry): void
|
||||||
* @param DateTime $firstEntry
|
|
||||||
*/
|
|
||||||
public function setFirstEntry(DateTime $firstEntry)
|
|
||||||
{
|
{
|
||||||
$this->firstEntry = $firstEntry;
|
$this->firstEntry = $firstEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return int
|
|
||||||
*/
|
|
||||||
public function getRecordsTotal(): int
|
public function getRecordsTotal(): int
|
||||||
{
|
{
|
||||||
return $this->recordsTotal;
|
return $this->recordsTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function setRecordsTotal(int $recordsTotal): void
|
||||||
* @param int $recordsTotal
|
|
||||||
* @return TimesheetStatistic
|
|
||||||
*/
|
|
||||||
public function setRecordsTotal(int $recordsTotal)
|
|
||||||
{
|
{
|
||||||
$this->recordsTotal = $recordsTotal;
|
$this->recordsTotal = $recordsTotal;
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ class TimesheetRepository extends EntityRepository
|
|||||||
* @return int|mixed
|
* @return int|mixed
|
||||||
* @throws \Doctrine\ORM\NonUniqueResultException
|
* @throws \Doctrine\ORM\NonUniqueResultException
|
||||||
*/
|
*/
|
||||||
public function getStatistic(string $type, ?DateTime $begin, ?DateTime $end, ?User $user)
|
public function getStatistic(string $type, ?DateTime $begin, ?DateTime $end, ?User $user, ?bool $billable = null)
|
||||||
{
|
{
|
||||||
switch ($type) {
|
switch ($type) {
|
||||||
case self::STATS_QUERY_ACTIVE:
|
case self::STATS_QUERY_ACTIVE:
|
||||||
@@ -262,6 +262,7 @@ class TimesheetRepository extends EntityRepository
|
|||||||
break;
|
break;
|
||||||
case self::STATS_QUERY_RATE:
|
case self::STATS_QUERY_RATE:
|
||||||
$what = 'COALESCE(SUM(t.rate), 0)';
|
$what = 'COALESCE(SUM(t.rate), 0)';
|
||||||
|
$billable = true;
|
||||||
break;
|
break;
|
||||||
case self::STATS_QUERY_USER:
|
case self::STATS_QUERY_USER:
|
||||||
$what = 'COUNT(DISTINCT(t.user))';
|
$what = 'COUNT(DISTINCT(t.user))';
|
||||||
@@ -273,7 +274,7 @@ class TimesheetRepository extends EntityRepository
|
|||||||
throw new InvalidArgumentException('Invalid query type: ' . $type);
|
throw new InvalidArgumentException('Invalid query type: ' . $type);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->queryTimeRange($what, $begin, $end, $user);
|
return $this->queryTimeRange($what, $begin, $end, $user, $billable);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -281,10 +282,11 @@ class TimesheetRepository extends EntityRepository
|
|||||||
* @param DateTime|null $begin
|
* @param DateTime|null $begin
|
||||||
* @param DateTime|null $end
|
* @param DateTime|null $end
|
||||||
* @param User|null $user
|
* @param User|null $user
|
||||||
|
* @param bool|null $billable
|
||||||
* @return int|mixed
|
* @return int|mixed
|
||||||
* @throws \Doctrine\ORM\NonUniqueResultException
|
* @throws \Doctrine\ORM\NonUniqueResultException
|
||||||
*/
|
*/
|
||||||
protected function queryTimeRange($select, ?DateTime $begin, ?DateTime $end, ?User $user)
|
protected function queryTimeRange($select, ?DateTime $begin, ?DateTime $end, ?User $user, ?bool $billable = null)
|
||||||
{
|
{
|
||||||
$selects = $select;
|
$selects = $select;
|
||||||
if (!\is_array($select)) {
|
if (!\is_array($select)) {
|
||||||
@@ -315,6 +317,11 @@ class TimesheetRepository extends EntityRepository
|
|||||||
->setParameter('user', $user);
|
->setParameter('user', $user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (null !== $billable) {
|
||||||
|
$qb->andWhere('t.billable = :billable')
|
||||||
|
->setParameter('billable', $billable);
|
||||||
|
}
|
||||||
|
|
||||||
if (\is_array($select)) {
|
if (\is_array($select)) {
|
||||||
return $qb->getQuery()->getOneOrNullResult();
|
return $qb->getQuery()->getOneOrNullResult();
|
||||||
}
|
}
|
||||||
@@ -331,31 +338,44 @@ class TimesheetRepository extends EntityRepository
|
|||||||
*/
|
*/
|
||||||
public function getUserStatistics(User $user, bool $bcSafe = true): TimesheetStatistic
|
public function getUserStatistics(User $user, bool $bcSafe = true): TimesheetStatistic
|
||||||
{
|
{
|
||||||
|
$stats = new TimesheetStatistic();
|
||||||
|
|
||||||
$allTimeData = $this->queryTimeRange([
|
$allTimeData = $this->queryTimeRange([
|
||||||
'COALESCE(SUM(t.duration), 0) as duration',
|
'COALESCE(SUM(t.duration), 0) as duration',
|
||||||
'COALESCE(SUM(t.rate), 0) as rate',
|
'COALESCE(SUM(t.rate), 0) as rate',
|
||||||
'COUNT(t.id) as amount'
|
'COUNT(t.id) as amount'
|
||||||
], null, null, $user);
|
], null, null, $user);
|
||||||
|
|
||||||
|
$stats->setAmountTotal($allTimeData['rate']);
|
||||||
|
$stats->setDurationTotal($allTimeData['duration']);
|
||||||
|
$stats->setRecordsTotal($allTimeData['amount']);
|
||||||
|
|
||||||
|
$billableAllTime = $this->getStatistic(self::STATS_QUERY_RATE, null, null, $user, true);
|
||||||
|
$stats->setRateTotalBillable($billableAllTime);
|
||||||
|
|
||||||
$timezone = new \DateTimeZone($user->getTimezone());
|
$timezone = new \DateTimeZone($user->getTimezone());
|
||||||
$begin = new DateTime('first day of this month 00:00:00', $timezone);
|
$begin = new DateTime('first day of this month 00:00:00', $timezone);
|
||||||
$end = new DateTime('last day of this month 23:59:59', $timezone);
|
$end = new DateTime('last day of this month 23:59:59', $timezone);
|
||||||
|
|
||||||
$monthData = $this->queryTimeRange(
|
$monthData = $this->queryTimeRange(
|
||||||
[
|
[
|
||||||
'COALESCE(SUM(t.rate), 0) as rate',
|
'COALESCE(SUM(t.rate), 0) as rate',
|
||||||
'COALESCE(SUM(t.duration), 0) as duration'
|
'COALESCE(SUM(t.duration), 0) as duration'
|
||||||
],
|
],
|
||||||
$begin,
|
$begin,
|
||||||
$end,
|
$end,
|
||||||
$user
|
$user
|
||||||
);
|
);
|
||||||
|
|
||||||
$stats = new TimesheetStatistic();
|
$stats->setAmountThisMonth($monthData['rate']);
|
||||||
|
$stats->setDurationThisMonth($monthData['duration']);
|
||||||
|
|
||||||
|
$billableMonth = $this->getStatistic(self::STATS_QUERY_RATE, $begin, $end, $user, true);
|
||||||
|
$stats->setRateThisMonthBillable($billableMonth);
|
||||||
|
|
||||||
if ($bcSafe) {
|
if ($bcSafe) {
|
||||||
$firstEntry = $this->getEntityManager()
|
$firstEntry = $this->getEntityManager()
|
||||||
->createQuery('SELECT MIN(t.begin) FROM ' . Timesheet::class . ' t WHERE t.user = :user AND 1=12')
|
->createQuery('SELECT MIN(t.begin) FROM ' . Timesheet::class . ' t WHERE t.user = :user')
|
||||||
->setParameter('user', $user)
|
->setParameter('user', $user)
|
||||||
->getSingleScalarResult();
|
->getSingleScalarResult();
|
||||||
|
|
||||||
@@ -372,12 +392,6 @@ class TimesheetRepository extends EntityRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$stats->setAmountTotal($allTimeData['rate']);
|
|
||||||
$stats->setDurationTotal($allTimeData['duration']);
|
|
||||||
$stats->setAmountThisMonth($monthData['rate']);
|
|
||||||
$stats->setDurationThisMonth($monthData['duration']);
|
|
||||||
$stats->setRecordsTotal($allTimeData['amount']);
|
|
||||||
|
|
||||||
return $stats;
|
return $stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,11 @@ final class TimesheetService
|
|||||||
public function stopTimesheet(Timesheet $timesheet): void
|
public function stopTimesheet(Timesheet $timesheet): void
|
||||||
{
|
{
|
||||||
if (null !== $timesheet->getEnd()) {
|
if (null !== $timesheet->getEnd()) {
|
||||||
throw new ValidationException('Timesheet entry already stopped');
|
// timesheet already stopped, nothing to do. in previous version, this method did throw a:
|
||||||
|
// new ValidationException('Timesheet entry already stopped');
|
||||||
|
// but this was removed, because it can happen in the frontend when using multiple tabs/devices and should
|
||||||
|
// simply be ignored - showing the message to the user with a "danger status" is not necessary
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$begin = clone $timesheet->getBegin();
|
$begin = clone $timesheet->getBegin();
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ final class LocaleHelper
|
|||||||
* @var NumberFormatter
|
* @var NumberFormatter
|
||||||
*/
|
*/
|
||||||
private $numberFormatter;
|
private $numberFormatter;
|
||||||
|
/**
|
||||||
|
* @var NumberFormatter
|
||||||
|
*/
|
||||||
|
private $durationFormatter;
|
||||||
/**
|
/**
|
||||||
* @var NumberFormatter
|
* @var NumberFormatter
|
||||||
*/
|
*/
|
||||||
@@ -51,10 +55,12 @@ final class LocaleHelper
|
|||||||
$value = 0;
|
$value = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->getNumberFormatter()->format((float) $value);
|
return $this->getDurationFormatter()->format((float) $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Only used in twig filter |amount and invoice templates
|
||||||
|
*
|
||||||
* @param string|float $amount
|
* @param string|float $amount
|
||||||
* @return bool|false|string
|
* @return bool|false|string
|
||||||
*/
|
*/
|
||||||
@@ -133,6 +139,16 @@ final class LocaleHelper
|
|||||||
return $this->numberFormatter;
|
return $this->numberFormatter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getDurationFormatter(): NumberFormatter
|
||||||
|
{
|
||||||
|
if (null === $this->numberFormatter) {
|
||||||
|
$this->durationFormatter = new NumberFormatter($this->locale, NumberFormatter::DECIMAL);
|
||||||
|
$this->durationFormatter->setAttribute(NumberFormatter::FRACTION_DIGITS, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->durationFormatter;
|
||||||
|
}
|
||||||
|
|
||||||
private function getMoneyFormatter(bool $withCurrency = true): NumberFormatter
|
private function getMoneyFormatter(bool $withCurrency = true): NumberFormatter
|
||||||
{
|
{
|
||||||
if ($withCurrency) {
|
if ($withCurrency) {
|
||||||
|
|||||||
@@ -155,7 +155,7 @@
|
|||||||
{# for @internal use only #}
|
{# for @internal use only #}
|
||||||
{% macro label_color_dot(type, isVisible, name, url, color) %}
|
{% macro label_color_dot(type, isVisible, name, url, color) %}
|
||||||
{% apply spaceless %}
|
{% apply spaceless %}
|
||||||
<span class="label-{{ type }}{{ isVisible ? '' : ' label-invisible' }}">
|
<span class="label-{{ type }}{{ isVisible is same as (false) ? ' label-invisible' : '' }}">
|
||||||
<span class="name">
|
<span class="name">
|
||||||
<i class="dot {{ 'dot'|icon }} fa-fw"{% if color is not empty %} style="color:{{ color }}"{% endif %}></i>
|
<i class="dot {{ 'dot'|icon }} fa-fw"{% if color is not empty %} style="color:{{ color }}"{% endif %}></i>
|
||||||
<span class="name-inner">
|
<span class="name-inner">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
{% set options = {'class': 'alwaysVisible text-center'} %}
|
{% set options = {'class': 'alwaysVisible text-center'} %}
|
||||||
{% if canEditPermissions and role.name not in system_roles|keys %}
|
{% if canEditPermissions and role.name not in system_roles|keys %}
|
||||||
{% set widget %}
|
{% set widget %}
|
||||||
<a href="{{ path('admin_user_role_delete', {'id': role.id, 'token': token}) }}" class="confirmation-link" data-question="confirm.delete">{{ widgets.icon('trash') }}</a>
|
<a href="{{ path('admin_user_role_delete', {'id': role.id, 'csrfToken': token}) }}" class="confirmation-link" data-question="confirm.delete">{{ widgets.icon('trash') }}</a>
|
||||||
{% endset %}
|
{% endset %}
|
||||||
{% set options = options|merge({'html_after': widget}) %}
|
{% set options = options|merge({'html_after': widget}) %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -47,10 +47,10 @@
|
|||||||
{% if value %}
|
{% if value %}
|
||||||
{{ widgets.label('yes'|trans, 'warning') }}
|
{{ widgets.label('yes'|trans, 'warning') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="togglePerm permOff" href="#" data-href="{{ path('admin_user_permission_save', {'id': role.id, 'name': permission, 'value': '1', 'token': '__TOKEN__'}) }}">{{ widgets.label('no'|trans, 'danger') }}</a>
|
<a class="togglePerm permOff" href="#" data-href="{{ path('admin_user_permission_save', {'id': role.id, 'name': permission, 'value': '1', 'csrfToken': '__TOKEN__'}) }}">{{ widgets.label('no'|trans, 'danger') }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="togglePerm {{ value ? 'permOn' : 'permOff' }}" href="#" data-href="{{ path('admin_user_permission_save', {'id': role.id, 'name': permission, 'value': '__VALUE__', 'token': '__TOKEN__'}) }}">{{ widgets.label_boolean(value) }}</a>
|
<a class="togglePerm {{ value ? 'permOn' : 'permOff' }}" href="#" data-href="{{ path('admin_user_permission_save', {'id': role.id, 'name': permission, 'value': '__VALUE__', 'csrfToken': '__TOKEN__'}) }}">{{ widgets.label_boolean(value) }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -130,8 +130,7 @@
|
|||||||
{% if seeOwnRate %}
|
{% if seeOwnRate %}
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ 'stats.amountMonth'|trans }}</th>
|
<th>{{ 'stats.amountMonth'|trans }}</th>
|
||||||
{# TODO which currency shall we use here? #}
|
<td class="text-nowrap pull-right">{{ stats.rateThisMonthBillable|money }}</td>
|
||||||
<td class="text-nowrap pull-right">{{ stats.amountThisMonth|money }}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -141,8 +140,7 @@
|
|||||||
{% if seeOwnRate %}
|
{% if seeOwnRate %}
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ 'stats.amountTotal'|trans }}</th>
|
<th>{{ 'stats.amountTotal'|trans }}</th>
|
||||||
{# TODO which currency shall we use here? #}
|
<td class="text-nowrap pull-right">{{ stats.rateTotalBillable|money }}</td>
|
||||||
<td class="text-nowrap pull-right">{{ stats.amountTotal|money }}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</table>
|
</table>
|
||||||
@@ -185,7 +183,7 @@
|
|||||||
{% if seeOwnRate %}
|
{% if seeOwnRate %}
|
||||||
<div class="col-sm-{{ columnLength }} border-right">
|
<div class="col-sm-{{ columnLength }} border-right">
|
||||||
<div class="description-block">
|
<div class="description-block">
|
||||||
<h5 class="description-header">{{ stats.amountThisMonth|money }}</h5>
|
<h5 class="description-header">{{ stats.rateThisMonthBillable|money }}</h5>
|
||||||
<span class="description-text">{{ 'stats.amountMonth'|trans }}</span>
|
<span class="description-text">{{ 'stats.amountMonth'|trans }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,7 +197,7 @@
|
|||||||
{% if seeOwnRate %}
|
{% if seeOwnRate %}
|
||||||
<div class="col-sm-{{ columnLength }} border-right">
|
<div class="col-sm-{{ columnLength }} border-right">
|
||||||
<div class="description-block">
|
<div class="description-block">
|
||||||
<h5 class="description-header">{{ stats.amountTotal|money }}</h5>
|
<h5 class="description-header">{{ stats.rateTotalBillable|money }}</h5>
|
||||||
<span class="description-text">{{ 'stats.amountTotal'|trans }}</span>
|
<span class="description-text">{{ 'stats.amountTotal'|trans }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -796,16 +796,6 @@ class TimesheetControllerTest extends APIControllerBaseTest
|
|||||||
$this->assertApiCallValidationError($client->getResponse(), ['duration' => 'Maximum 12:30 hours allowed.']);
|
$this->assertApiCallValidationError($client->getResponse(), ['duration' => 'Maximum 12:30 hours allowed.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testStopActionFailsOnStoppedEntry()
|
|
||||||
{
|
|
||||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
|
||||||
$timesheets = $this->importFixtureForUser(User::ROLE_USER);
|
|
||||||
$id = $timesheets[0]->getId();
|
|
||||||
$this->request($client, '/api/timesheets/' . $id . '/stop', 'PATCH');
|
|
||||||
|
|
||||||
$this->assertApi500Exception($client->getResponse(), 'Timesheet entry already stopped');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testStopThrowsNotFound()
|
public function testStopThrowsNotFound()
|
||||||
{
|
{
|
||||||
$this->assertEntityNotFoundForPatch(User::ROLE_USER, '/api/timesheets/11/stop', [], 'App\\Entity\\Timesheet object not found by the @ParamConverter annotation.');
|
$this->assertEntityNotFoundForPatch(User::ROLE_USER, '/api/timesheets/11/stop', [], 'App\\Entity\\Timesheet object not found by the @ParamConverter annotation.');
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ use App\Event\TimesheetRestartPreEvent;
|
|||||||
use App\Repository\TimesheetRepository;
|
use App\Repository\TimesheetRepository;
|
||||||
use App\Timesheet\TimesheetService;
|
use App\Timesheet\TimesheetService;
|
||||||
use App\Timesheet\TrackingModeService;
|
use App\Timesheet\TrackingModeService;
|
||||||
use App\Validator\ValidationException;
|
|
||||||
use App\Validator\ValidationFailedException;
|
use App\Validator\ValidationFailedException;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||||
@@ -246,15 +245,15 @@ class TimesheetServiceTest extends TestCase
|
|||||||
|
|
||||||
public function testStoppedEntriesCannotBeStoppedAgain()
|
public function testStoppedEntriesCannotBeStoppedAgain()
|
||||||
{
|
{
|
||||||
|
$dateTime = new \DateTime('-2 hours');
|
||||||
$timesheet = new Timesheet();
|
$timesheet = new Timesheet();
|
||||||
$timesheet->setEnd(new \DateTime());
|
$timesheet->setEnd($dateTime);
|
||||||
|
|
||||||
$sut = $this->getSut();
|
$sut = $this->getSut();
|
||||||
|
|
||||||
$this->expectException(ValidationException::class);
|
|
||||||
$this->expectExceptionMessage('Timesheet entry already stopped');
|
|
||||||
|
|
||||||
$sut->stopTimesheet($timesheet);
|
$sut->stopTimesheet($timesheet);
|
||||||
|
|
||||||
|
self::assertSame($dateTime->getTimestamp(), $timesheet->getEnd()->getTimestamp());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDeleteDispatchesEvent()
|
public function testDeleteDispatchesEvent()
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ class LocaleFormatExtensionsTest extends TestCase
|
|||||||
$sut = $this->getSut($this->localeEn, 'en');
|
$sut = $this->getSut($this->localeEn, 'en');
|
||||||
|
|
||||||
$this->assertEquals('00:00 h', $sut->duration(null));
|
$this->assertEquals('00:00 h', $sut->duration(null));
|
||||||
$this->assertEquals('0', $sut->duration(null, true));
|
$this->assertEquals('0.00', $sut->duration(null, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDurationChart()
|
public function testDurationChart()
|
||||||
@@ -483,15 +483,15 @@ class LocaleFormatExtensionsTest extends TestCase
|
|||||||
|
|
||||||
// test negative duration
|
// test negative duration
|
||||||
$sut = $this->getSut($this->localeEn, 'en');
|
$sut = $this->getSut($this->localeEn, 'en');
|
||||||
$this->assertEquals('0', $sut->durationDecimal(-1));
|
$this->assertEquals('0.00', $sut->durationDecimal(-1));
|
||||||
|
|
||||||
// test zero duration
|
// test zero duration
|
||||||
$sut = $this->getSut($this->localeEn, 'en');
|
$sut = $this->getSut($this->localeEn, 'en');
|
||||||
$this->assertEquals('0', $sut->durationDecimal(0));
|
$this->assertEquals('0.00', $sut->durationDecimal(0));
|
||||||
|
|
||||||
$sut = $this->getSut($this->localeEn, 'en');
|
$sut = $this->getSut($this->localeEn, 'en');
|
||||||
|
|
||||||
$this->assertEquals('0', $sut->durationDecimal(null));
|
$this->assertEquals('0.00', $sut->durationDecimal(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getTimesheet($seconds)
|
protected function getTimesheet($seconds)
|
||||||
|
|||||||
@@ -199,18 +199,18 @@ class LocaleHelperTest extends TestCase
|
|||||||
$sut = $this->getSut('de');
|
$sut = $this->getSut('de');
|
||||||
$this->assertEquals('2,62', $sut->durationDecimal($record->getDuration()));
|
$this->assertEquals('2,62', $sut->durationDecimal($record->getDuration()));
|
||||||
$this->assertEquals('6.328,89', $sut->durationDecimal(22784012));
|
$this->assertEquals('6.328,89', $sut->durationDecimal(22784012));
|
||||||
$this->assertEquals('1', $sut->durationDecimal(3600));
|
$this->assertEquals('1,00', $sut->durationDecimal(3600));
|
||||||
$this->assertEquals('1,01', $sut->durationDecimal(3630));
|
$this->assertEquals('1,01', $sut->durationDecimal(3630));
|
||||||
$this->assertEquals('1,02', $sut->durationDecimal(3661));
|
$this->assertEquals('1,02', $sut->durationDecimal(3661));
|
||||||
$this->assertEquals('1,1', $sut->durationDecimal(3960));
|
$this->assertEquals('1,10', $sut->durationDecimal(3960));
|
||||||
|
|
||||||
// test negative duration
|
// test negative duration
|
||||||
$sut = $this->getSut('en');
|
$sut = $this->getSut('en');
|
||||||
$this->assertEquals('0', $sut->durationDecimal(-1));
|
$this->assertEquals('0.00', $sut->durationDecimal(-1));
|
||||||
|
|
||||||
// test zero duration
|
// test zero duration
|
||||||
$sut = $this->getSut('en');
|
$sut = $this->getSut('en');
|
||||||
$this->assertEquals('0', $sut->durationDecimal(0));
|
$this->assertEquals('0.00', $sut->durationDecimal(0));
|
||||||
$this->assertEquals('6,328.89', $sut->durationDecimal(22784012));
|
$this->assertEquals('6,328.89', $sut->durationDecimal(22784012));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -460,7 +460,7 @@
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="label.my_team_projects">
|
<trans-unit id="label.my_team_projects">
|
||||||
<source>label.my_team_projects</source>
|
<source>label.my_team_projects</source>
|
||||||
<target>Team Projekte & Budgets</target>
|
<target>Meine Projekte</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="label.progress">
|
<trans-unit id="label.progress">
|
||||||
<source>label.progress</source>
|
<source>label.progress</source>
|
||||||
@@ -1126,6 +1126,10 @@
|
|||||||
<source>export.warn_result_amount</source>
|
<source>export.warn_result_amount</source>
|
||||||
<target>Ihre Suche führt zu %count% Ergebnissen. Sollte der Export fehlschlagen, müssen Sie die Suche weiter eingrenzen.</target>
|
<target>Ihre Suche führt zu %count% Ergebnissen. Sollte der Export fehlschlagen, müssen Sie die Suche weiter eingrenzen.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="label.type">
|
||||||
|
<source>label.type</source>
|
||||||
|
<target>Typ</target>
|
||||||
|
</trans-unit>
|
||||||
<!--
|
<!--
|
||||||
Navbar - recent entries and activities
|
Navbar - recent entries and activities
|
||||||
-->
|
-->
|
||||||
|
|||||||
@@ -683,7 +683,7 @@
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="label.my_team_projects">
|
<trans-unit id="label.my_team_projects">
|
||||||
<source>label.my_team_projects</source>
|
<source>label.my_team_projects</source>
|
||||||
<target>Team Projekte & Budgets</target>
|
<target>Meine Projekte</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="label.my_teams">
|
<trans-unit id="label.my_teams">
|
||||||
<source>label.my_teams</source>
|
<source>label.my_teams</source>
|
||||||
|
|||||||
@@ -460,7 +460,7 @@
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="label.my_team_projects">
|
<trans-unit id="label.my_team_projects">
|
||||||
<source>label.my_team_projects</source>
|
<source>label.my_team_projects</source>
|
||||||
<target>Team projects & budgets</target>
|
<target>My projects</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="label.progress">
|
<trans-unit id="label.progress">
|
||||||
<source>label.progress</source>
|
<source>label.progress</source>
|
||||||
@@ -1126,6 +1126,10 @@
|
|||||||
<source>export.warn_result_amount</source>
|
<source>export.warn_result_amount</source>
|
||||||
<target>Your search leads to %count% results. If the export fails, you have to narrow down your search further.</target>
|
<target>Your search leads to %count% results. If the export fails, you have to narrow down your search further.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="label.type">
|
||||||
|
<source>label.type</source>
|
||||||
|
<target>Type</target>
|
||||||
|
</trans-unit>
|
||||||
<!--
|
<!--
|
||||||
Navbar - recent entries and activities
|
Navbar - recent entries and activities
|
||||||
-->
|
-->
|
||||||
|
|||||||
Reference in New Issue
Block a user