Release 2.0.5 (#3888)

- Fixed: mandatory fields / form validation for invoice template
- Added: Duration as calendar title replacer (open: running records)
- Fixed: HTML injection in Calendar
- Fixed: Doctrine Proxies are not initialized (leads to empty customer/project)
- Fixed: tag creation
- Fixed: validation errors do not need to be logged in prod
- Removed: Customer VCard download, as used library is outdated and not maintained
- Added: supporting translations domains in many new places (menu, help_text, page_setup, report, exporter, calendar)
This commit is contained in:
Kevin Papst
2023-03-05 03:08:43 +01:00
committed by GitHub
parent 3b93afabc8
commit 0cbf0053d2
77 changed files with 1306 additions and 874 deletions

View File

@@ -227,8 +227,6 @@ export default class KimaiDateUtils extends KimaiPlugin {
}
/**
* TODO remove seconds
*
* @param {int} hours
* @param {int} minutes
* @return {string}

View File

@@ -536,12 +536,22 @@ export default class KimaiCalendar {
color = defaultColor;
}
/** @type {KimaiDateUtils} DATES */
const DATES = this.kimai.getPlugin('date');
let title = this.options['patterns']['title'];
title = title.replace('{project}', apiItem.project.name);
title = title.replace('{customer}', apiItem.project.customer.name);
title = title.replace('{description}', apiItem.description ?? '');
title = title.replace('{activity}', apiItem.activity.name ?? '');
if (apiItem.end === null) {
// duration = 0 and end = null => is a running entry
title = title.replace('{duration}', '');
} else {
title = title.replace('{duration}', DATES.formatDuration(apiItem.duration));
}
if (title === '' || title === null) {
title = apiItem.activity.name;
}
@@ -573,6 +583,13 @@ export default class KimaiCalendar {
/** @type {KimaiEscape} escaper */
const escaper = this.kimai.getPlugin('escape');
let tags = '';
if (eventObj.tags !== null && eventObj.tags.length > 0) {
for (let tag of eventObj.tags) {
tags += '<span class="badge bg-green">' + escaper.escapeForHtml(tag) + '</span>';
}
}
return `
<div class="calendar-entry">
<ul>
@@ -581,8 +598,7 @@ export default class KimaiCalendar {
<li>` + this.options['translations']['activity'] + `: ` + escaper.escapeForHtml(eventObj.activity) + `</li>
</ul>` +
(eventObj.description !== null || eventObj.tags.length > 0 ? '<hr>' : '') +
(eventObj.description ? '<p>' + eventObj.description + '</p>' : '') +
(eventObj.tags !== null && eventObj.tags.length > 0 ? '<span class="badge bg-green">' + eventObj.tags.join('</span> <span class="badge bg-green">') + '</span>' : '') + `
(eventObj.description ? '<p>' + escaper.escapeForHtml(eventObj.description) + '</p>' : '') + tags + `
</div>`;
}

View File

@@ -33,9 +33,8 @@
"erusev/parsedown": "^1.6",
"friendsofsymfony/rest-bundle": "^3.0",
"gedmo/doctrine-extensions": "^3.6",
"jeroendesloovere/vcard": "^1.7",
"jms/serializer-bundle": "^5.0",
"kevinpapst/tabler-bundle": "^0.10",
"kevinpapst/tabler-bundle": "^0.12",
"league/csv": "^9.4",
"mpdf/mpdf": "^8.0",
"nelmio/api-doc-bundle": "^4.0",

915
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,8 @@
framework:
exceptions:
App\Validator\ValidationFailedException:
log_level: debug
secret: '%env(APP_SECRET)%'
default_locale: en
csrf_protection: true

View File

@@ -2060,36 +2060,11 @@ parameters:
count: 1
path: src/Entity/Customer.php
-
message: "#^Method App\\\\Entity\\\\CustomerComment\\:\\:setCreatedAt\\(\\) has no return type specified\\.$#"
count: 1
path: src/Entity/CustomerComment.php
-
message: "#^Method App\\\\Entity\\\\CustomerComment\\:\\:setCreatedBy\\(\\) has no return type specified\\.$#"
count: 1
path: src/Entity/CustomerComment.php
-
message: "#^Method App\\\\Entity\\\\CustomerComment\\:\\:setMessage\\(\\) has no return type specified\\.$#"
count: 1
path: src/Entity/CustomerComment.php
-
message: "#^Method App\\\\Entity\\\\CustomerComment\\:\\:setPinned\\(\\) has no return type specified\\.$#"
count: 1
path: src/Entity/CustomerComment.php
-
message: "#^Method App\\\\Entity\\\\EntityWithMetaFields\\:\\:getMetaFields\\(\\) return type with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection does not specify its types\\: TKey, T$#"
count: 1
path: src/Entity/EntityWithMetaFields.php
-
message: "#^Cannot call method getDueDays\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
count: 1
path: src/Entity/Invoice.php
-
message: "#^Cannot call method getTax\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
count: 1
@@ -2100,11 +2075,6 @@ parameters:
count: 1
path: src/Entity/Invoice.php
-
message: "#^Cannot call method getVat\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
count: 1
path: src/Entity/Invoice.php
-
message: "#^Method App\\\\Entity\\\\Invoice\\:\\:getMetaFields\\(\\) return type with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection does not specify its types\\: TKey, T$#"
count: 1
@@ -2185,26 +2155,6 @@ parameters:
count: 1
path: src/Entity/Project.php
-
message: "#^Method App\\\\Entity\\\\ProjectComment\\:\\:setCreatedAt\\(\\) has no return type specified\\.$#"
count: 1
path: src/Entity/ProjectComment.php
-
message: "#^Method App\\\\Entity\\\\ProjectComment\\:\\:setCreatedBy\\(\\) has no return type specified\\.$#"
count: 1
path: src/Entity/ProjectComment.php
-
message: "#^Method App\\\\Entity\\\\ProjectComment\\:\\:setMessage\\(\\) has no return type specified\\.$#"
count: 1
path: src/Entity/ProjectComment.php
-
message: "#^Method App\\\\Entity\\\\ProjectComment\\:\\:setPinned\\(\\) has no return type specified\\.$#"
count: 1
path: src/Entity/ProjectComment.php
-
message: "#^Method App\\\\Entity\\\\Tag\\:\\:__toString\\(\\) should return string but returns string\\|null\\.$#"
count: 1
@@ -3185,11 +3135,6 @@ parameters:
count: 1
path: src/Form/API/TimesheetApiEditForm.php
-
message: "#^Strict comparison using \\=\\=\\= between false and false will always evaluate to true\\.$#"
count: 1
path: src/Form/API/TimesheetApiEditForm.php
-
message: "#^Method App\\\\Form\\\\AbstractRateForm\\:\\:addFields\\(\\) has no return type specified\\.$#"
count: 1
@@ -3335,11 +3280,6 @@ parameters:
count: 1
path: src/Form/Extension/SelectWithApiDataExtension.php
-
message: "#^Strict comparison using \\!\\=\\= between non\\-falsy\\-string and '' will always evaluate to true\\.$#"
count: 1
path: src/Form/Extension/SelectWithApiDataExtension.php
-
message: "#^Parameter \\#1 \\$haystack of function stripos expects string, bool\\|float\\|int\\|string given\\.$#"
count: 2
@@ -4970,11 +4910,6 @@ parameters:
count: 1
path: src/Form/Type/TeamType.php
-
message: "#^Cannot cast mixed to int\\.$#"
count: 1
path: src/Form/Type/TimePickerType.php
-
message: "#^Parameter \\#1 \\$format of static method DateTime\\:\\:createFromFormat\\(\\) expects string, mixed given\\.$#"
count: 1
@@ -5405,11 +5340,6 @@ parameters:
count: 1
path: src/Invoice/Renderer/AbstractTwigRenderer.php
-
message: "#^Strict comparison using \\!\\=\\= between float and \\-1 will always evaluate to true\\.$#"
count: 1
path: src/Invoice/Renderer/AdvancedValueBinder.php
-
message: "#^Cannot call method getEntries\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
count: 3
@@ -5635,11 +5565,6 @@ parameters:
count: 1
path: src/Ldap/LdapManager.php
-
message: "#^PHPDoc tag @var for variable \\$email has no value type specified in iterable type array\\.$#"
count: 1
path: src/Ldap/LdapManager.php
-
message: "#^Parameter \\#1 \\$baseDn of method App\\\\Ldap\\\\LdapDriver\\:\\:search\\(\\) expects string, bool\\|float\\|int\\|string given\\.$#"
count: 1
@@ -5965,16 +5890,6 @@ parameters:
count: 1
path: src/Project/ProjectStatisticService.php
-
message: "#^Strict comparison using \\!\\=\\= between DateTime and false will always evaluate to true\\.$#"
count: 1
path: src/Project/ProjectStatisticService.php
-
message: "#^Strict comparison using \\!\\=\\= between null and list\\<array\\{id\\: int\\|numeric\\-string, duration\\: int\\|numeric\\-string, rate\\: float\\|int\\|numeric\\-string, internalRate\\: float\\|int\\|numeric\\-string, counter\\: int\\<0, max\\>\\|numeric\\-string, billable\\: bool, exported\\: bool\\}\\> will always evaluate to true\\.$#"
count: 1
path: src/Project/ProjectStatisticService.php
-
message: "#^Property App\\\\Reporting\\\\AbstractUserList\\:\\:\\$date has no type specified\\.$#"
count: 1
@@ -6065,21 +5980,11 @@ parameters:
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Parameter \\#2 \\$lockMode \\(null\\) of method App\\\\Repository\\\\ActivityRepository\\:\\:find\\(\\) should be contravariant with parameter \\$lockMode \\(int\\|null\\) of method Doctrine\\\\ORM\\\\EntityRepository\\<App\\\\Entity\\\\Activity\\>\\:\\:find\\(\\)$#"
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, iterable\\<App\\\\Entity\\\\Team\\> given\\.$#"
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Parameter \\#3 \\$lockVersion \\(null\\) of method App\\\\Repository\\\\ActivityRepository\\:\\:find\\(\\) should be contravariant with parameter \\$lockVersion \\(int\\|null\\) of method Doctrine\\\\ORM\\\\EntityRepository\\<App\\\\Entity\\\\Activity\\>\\:\\:find\\(\\)$#"
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Method App\\\\Repository\\\\BookmarkRepository\\:\\:deleteBookmark\\(\\) has no return type specified\\.$#"
count: 1
@@ -6160,21 +6065,11 @@ parameters:
count: 1
path: src/Repository/CustomerRepository.php
-
message: "#^Parameter \\#2 \\$lockMode \\(null\\) of method App\\\\Repository\\\\CustomerRepository\\:\\:find\\(\\) should be contravariant with parameter \\$lockMode \\(int\\|null\\) of method Doctrine\\\\ORM\\\\EntityRepository\\<App\\\\Entity\\\\Customer\\>\\:\\:find\\(\\)$#"
count: 1
path: src/Repository/CustomerRepository.php
-
message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, iterable\\<App\\\\Entity\\\\Team\\> given\\.$#"
count: 1
path: src/Repository/CustomerRepository.php
-
message: "#^Parameter \\#3 \\$lockVersion \\(null\\) of method App\\\\Repository\\\\CustomerRepository\\:\\:find\\(\\) should be contravariant with parameter \\$lockVersion \\(int\\|null\\) of method Doctrine\\\\ORM\\\\EntityRepository\\<App\\\\Entity\\\\Customer\\>\\:\\:find\\(\\)$#"
count: 1
path: src/Repository/CustomerRepository.php
-
message: "#^Cannot access offset 'counter' on mixed\\.$#"
count: 1
@@ -6405,21 +6300,11 @@ parameters:
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Parameter \\#2 \\$lockMode \\(null\\) of method App\\\\Repository\\\\ProjectRepository\\:\\:find\\(\\) should be contravariant with parameter \\$lockMode \\(int\\|null\\) of method Doctrine\\\\ORM\\\\EntityRepository\\<App\\\\Entity\\\\Project\\>\\:\\:find\\(\\)$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, iterable\\<App\\\\Entity\\\\Team\\> given\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Parameter \\#3 \\$lockVersion \\(null\\) of method App\\\\Repository\\\\ProjectRepository\\:\\:find\\(\\) should be contravariant with parameter \\$lockVersion \\(int\\|null\\) of method Doctrine\\\\ORM\\\\EntityRepository\\<App\\\\Entity\\\\Project\\>\\:\\:find\\(\\)$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\Query\\\\BaseQuery\\:\\:getOrderGroups\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -6735,21 +6620,11 @@ parameters:
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Parameter \\#2 \\$lockMode \\(null\\) of method App\\\\Repository\\\\TimesheetRepository\\:\\:find\\(\\) should be contravariant with parameter \\$lockMode \\(int\\|null\\) of method Doctrine\\\\ORM\\\\EntityRepository\\<App\\\\Entity\\\\Timesheet\\>\\:\\:find\\(\\)$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, iterable\\<App\\\\Entity\\\\Team\\> given\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Parameter \\#3 \\$lockVersion \\(null\\) of method App\\\\Repository\\\\TimesheetRepository\\:\\:find\\(\\) should be contravariant with parameter \\$lockVersion \\(int\\|null\\) of method Doctrine\\\\ORM\\\\EntityRepository\\<App\\\\Entity\\\\Timesheet\\>\\:\\:find\\(\\)$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Cannot call method getSearchFields\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1

File diff suppressed because one or more lines are too long

View File

@@ -45,7 +45,7 @@
"calendar": {
"js": [
"/build/runtime.f0079159.js",
"/build/calendar.3041e41e.js"
"/build/calendar.94fe23d5.js"
],
"css": [
"/build/calendar.bb473428.css"
@@ -72,7 +72,7 @@
"/build/invoice-pdf.d86b82ee.js": "sha384-A0HJqP+MvEqQr1uG8wViCeEWxBRKyS6l8D+Ao4pFYHUA12gCC1gRYhk9I+SJPvZq",
"/build/invoice-pdf.c88953bb.css": "sha384-ZvSi1e+ZKGzvZJUtAPLjzOSTh13N9zRevq44GKdYdBja/DAplGE55saY2Ur+83yv",
"/build/chart.f5becfac.js": "sha384-GSqETm8wULiVXyizvwRompfwu63r/C0Qd/AvrHDE4cqAKiIGCssb3QyBtGu1WN+W",
"/build/calendar.3041e41e.js": "sha384-vdX0khIprtUhM4P8aiSsY5H3YSjHM1OMNdiYIhoKy/fm//5gwaEPkSbZDsTbe5dT",
"/build/calendar.94fe23d5.js": "sha384-7STPDBwT7nqGf6WWeoWJ2/KyDsf8J7cs9hw/zcYQefgKlzfexbxdZsIJxFxoK1Bu",
"/build/calendar.bb473428.css": "sha384-900W9o8666Qpw21rLP7hn5Ql8tWd40k0IcE3QpFm7E7V6bKva7xQgGlyzVuuh4GK",
"/build/dashboard.6774a712.js": "sha384-lBwkNqUPv+IBbznagiFakY2BOIueq/Bg89wHO2XeM9YAZ51rPkrvdMd+XmFupHsM",
"/build/dashboard.18f5a8b7.css": "sha384-PBD8ftb2yBoSjRe2sN5Xb4dEz045kOEUfcIufdSis+tWoWdAx5j4Yic+F/6CYbrP"

View File

@@ -9,7 +9,7 @@
"build/invoice-pdf.js": "/build/invoice-pdf.d86b82ee.js",
"build/chart.js": "/build/chart.f5becfac.js",
"build/calendar.css": "/build/calendar.bb473428.css",
"build/calendar.js": "/build/calendar.3041e41e.js",
"build/calendar.js": "/build/calendar.94fe23d5.js",
"build/dashboard.css": "/build/dashboard.18f5a8b7.css",
"build/dashboard.js": "/build/dashboard.6774a712.js",
"build/runtime.js": "/build/runtime.f0079159.js"

View File

@@ -11,14 +11,10 @@ namespace App\Calendar;
interface DragAndDropSource
{
/**
* @return string
*/
public function getTitle(): string;
/**
* @return string
*/
public function getTranslationDomain(): string;
public function getRoute(): string;
/**
@@ -31,9 +27,6 @@ interface DragAndDropSource
*/
public function getRouteReplacer(): array;
/**
* @return string
*/
public function getMethod(): string;
/**

View File

@@ -23,6 +23,11 @@ final class RecentActivitiesSource implements DragAndDropSource
return 'recent.activities';
}
public function getTranslationDomain(): string
{
return 'messages';
}
public function getRoute(): string
{
return 'post_timesheet';

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.0.4';
public const VERSION = '2.0.5';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 20004;
public const VERSION_ID = 20005;
/**
* The software name
*/

View File

@@ -35,16 +35,12 @@ use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\TeamRepository;
use App\Utils\DataTable;
use App\Utils\FileHelper;
use App\Utils\PageSetup;
use JeroenDesloovere\VCard\VCard;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Intl\Countries;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
@@ -361,82 +357,6 @@ final class CustomerController extends AbstractController
]);
}
#[Route(path: '/{id}/vcard', name: 'customer_vcard', methods: ['GET'])]
#[IsGranted('view', 'customer')]
public function downloadVCard(Customer $customer): Response
{
if ($customer->getName() === null || \strlen($customer->getName()) === 0) {
throw new \Exception('Customer name cannot be null');
}
$vcard = new VCard();
$contact = $customer->getContact();
if ($contact === null || \strlen($contact) === 0) {
$contact = $customer->getName();
}
$contact = explode(' ', $contact);
$lastname = array_pop($contact);
$firstname = \count($contact) > 0 ? $contact[0] : '';
$vcard->addName($lastname, $firstname);
$note = $customer->getComment();
if ($note !== null) {
$note .= PHP_EOL;
}
$address = $customer->getAddress();
if (!empty($note) || !empty($address)) {
$vcard->addNote($note . $address);
}
$country = $customer->getCountry();
if ($country !== null) {
$vcard->addAddress(null, null, null, null, null, null, Countries::getName($country));
}
$company = $customer->getCompany();
if ($company !== null) {
$vcard->addCompany($company);
}
$email = $customer->getEmail();
if ($email !== null) {
$vcard->addEmail($email);
}
$hasPref = false;
if ($customer->getPhone() !== null) {
$hasPref = true;
$vcard->addPhoneNumber($customer->getPhone(), 'PREF;WORK');
}
if ($customer->getMobile() !== null) {
$type = $hasPref ? 'CELL' : 'PREF;CELL';
$vcard->addPhoneNumber($customer->getMobile(), $type);
}
if ($customer->getFax() !== null) {
$vcard->addPhoneNumber($customer->getFax(), 'FAX');
}
if ($customer->getHomepage() !== null) {
$vcard->addURL($customer->getHomepage(), 'WORK');
}
$response = new Response($vcard->getOutput());
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
FileHelper::convertToAsciiFilename($customer->getName()) . '.vcf'
);
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
#[Route(path: '/{id}/rate/{rate}', name: 'admin_customer_rate_edit', methods: ['GET', 'POST'])]
#[IsGranted('edit', 'customer')]
public function editRateAction(Customer $customer, CustomerRate $rate, Request $request, CustomerRateRepository $repository): Response

View File

@@ -65,8 +65,8 @@ final class ExportController extends AbstractController
];
}
$byCustomer[$cid]['rate'] += $entry->getRate();
$byCustomer[$cid]['internalRate'] += $entry->getInternalRate();
$byCustomer[$cid]['duration'] += $entry->getDuration();
$byCustomer[$cid]['internalRate'] += $entry->getInternalRate() ?? 0.0;
$byCustomer[$cid]['duration'] += $entry->getDuration() ?? 0;
}
} catch (TooManyItemsExportException $ex) {
$tooManyResults = true;

View File

@@ -91,7 +91,6 @@ final class TeamFixtures extends Fixture
$team = new Team($faker->company() . ' ' . $i);
$team->addTeamlead($allUsers[array_rand($allUsers)]);
/* @phpstan-ignore-next-line */
if ($userCount > 0) {
$userKeys = array_rand($allUsers, $userCount);
if (!\is_array($userKeys)) {

View File

@@ -69,7 +69,7 @@ final class AppExtension extends Extension
}
// this should happen always at the end, so bundles do not mess with the base configuration
if ($container->hasParameter('kimai.bundles.config')) { // @phpstan-ignore-line
if ($container->hasParameter('kimai.bundles.config')) {
$bundleConfig = $container->getParameter('kimai.bundles.config');
if (!\is_array($bundleConfig)) {
throw new \Exception('Invalid bundle configuration found, skipping all bundle configuration');

View File

@@ -42,7 +42,7 @@ trait CommentTableTypeTrait
return $this->message;
}
public function setMessage(string $message)
public function setMessage(string $message): void
{
$this->message = $message;
}
@@ -52,7 +52,7 @@ trait CommentTableTypeTrait
return $this->createdBy;
}
public function setCreatedBy(User $createdBy)
public function setCreatedBy(User $createdBy): void
{
$this->createdBy = $createdBy;
}
@@ -62,7 +62,7 @@ trait CommentTableTypeTrait
return $this->createdAt;
}
public function setCreatedAt(\DateTime $createdAt)
public function setCreatedAt(\DateTime $createdAt): void
{
$this->createdAt = $createdAt;
}
@@ -72,7 +72,7 @@ trait CommentTableTypeTrait
return $this->pinned;
}
public function setPinned(bool $pinned)
public function setPinned(bool $pinned): void
{
$this->pinned = $pinned;
}

View File

@@ -190,6 +190,16 @@ class Invoice implements EntityWithMetaFields
public function setModel(InvoiceModel $model): Invoice
{
$template = $model->getTemplate();
if ($template === null) {
throw new \InvalidArgumentException('Missing invoice template');
}
if ($template->getDueDays() === null || $template->getVat() === null) {
throw new \InvalidArgumentException('Missing due-days or vat setting');
}
$this->customer = $model->getCustomer();
$this->user = $model->getUser();
$this->total = $model->getCalculator()->getTotal();
@@ -201,7 +211,6 @@ class Invoice implements EntityWithMetaFields
$this->createdAt = $createdAt;
$this->timezone = $createdAt->getTimezone()->getName();
$template = $model->getTemplate();
$this->dueDays = $template->getDueDays();
$this->vat = $template->getVat();

View File

@@ -42,11 +42,13 @@ class InvoiceTemplate
#[ORM\Column(name: 'contact', type: 'text', nullable: true)]
private ?string $contact = null;
#[ORM\Column(name: 'due_days', type: 'integer', length: 3, nullable: false)]
#[Assert\NotNull]
#[Assert\Range(min: 0, max: 999)]
private int $dueDays = 30;
private ?int $dueDays = 30;
#[ORM\Column(name: 'vat', type: 'float', nullable: false)]
#[Assert\NotNull]
#[Assert\Range(min: 0.0, max: 99.99)]
private float $vat = 0.00;
private ?float $vat = 0.00;
#[ORM\Column(name: 'calculator', type: 'string', length: 20, nullable: false)]
#[Assert\NotBlank]
#[Assert\Length(max: 20)]
@@ -94,7 +96,7 @@ class InvoiceTemplate
return $this->title;
}
public function setTitle(string $title): InvoiceTemplate
public function setTitle(?string $title): InvoiceTemplate
{
$this->title = $title;
@@ -125,24 +127,24 @@ class InvoiceTemplate
return $this;
}
public function getDueDays(): int
public function getDueDays(): ?int
{
return $this->dueDays;
}
public function setDueDays(int $dueDays): InvoiceTemplate
public function setDueDays(?int $dueDays): InvoiceTemplate
{
$this->dueDays = $dueDays;
return $this;
}
public function getVat(): float
public function getVat(): ?float
{
return $this->vat;
}
public function setVat(float $vat): InvoiceTemplate
public function setVat(?float $vat): InvoiceTemplate
{
$this->vat = $vat;
@@ -154,7 +156,7 @@ class InvoiceTemplate
return $this->company;
}
public function setCompany(string $company): InvoiceTemplate
public function setCompany(?string $company): InvoiceTemplate
{
$this->company = $company;

View File

@@ -47,10 +47,6 @@ final class CustomerSubscriber extends AbstractActionsSubscriber
$event->addAction('permissions', ['title' => 'permissions', 'translation_domain' => 'actions', 'url' => $this->path('admin_customer_permissions', ['id' => $customer->getId()]), 'class' => $class]);
}
if ($canView && $event->isIndexView()) {
$event->addAction('vcard', ['title' => 'vcard', 'translation_domain' => 'actions', 'icon' => 'far fa-address-card', 'url' => $this->path('customer_vcard', ['id' => $customer->getId()])]);
}
if ($isListingView) {
if ($customer->isVisible() && $this->isGranted('create_project')) {
$event->addAction('create-project', [

View File

@@ -21,7 +21,7 @@ final class InvoiceDocumentSubscriber extends AbstractActionsSubscriber
public function onActions(PageActionsEvent $event): void
{
/** @var array<string, InvoiceDocument|null|bool> $payload */
/** @var array<string, InvoiceDocument|null|bool|string> $payload */
$payload = $event->getPayload();
if (!\is_array($payload)) {
return;

View File

@@ -14,7 +14,7 @@ final class Expose
{
public string $type = 'string';
public function __construct(public ?string $name = null, public ?string $label = null, string $type = 'string', public ?string $exp = null)
public function __construct(public ?string $name = null, public ?string $label = null, string $type = 'string', public ?string $exp = null, public ?string $translationDomain = null)
{
if (!\in_array($type, ['string', 'datetime', 'date', 'time', 'integer', 'float', 'duration', 'boolean', 'array'])) {
throw new \InvalidArgumentException(sprintf('Unknown type "%s" on annotation "%s".', $type, self::class));

View File

@@ -135,7 +135,7 @@ trait RendererTrait
}
$rate = $exportItem->getRate();
$internalRate = $exportItem->getInternalRate();
$internalRate = $exportItem->getInternalRate() ?? 0;
// rate
$summary[$id]['rate'] += $rate;

View File

@@ -13,6 +13,8 @@ final class ColumnDefinition
{
private $accessor;
private string $translationDomain = 'messages';
public function __construct(private string $label, private string $type, callable $accessor)
{
$this->accessor = $accessor;
@@ -32,4 +34,14 @@ final class ColumnDefinition
{
return $this->accessor;
}
public function getTranslationDomain(): string
{
return $this->translationDomain;
}
public function setTranslationDomain(string $translationDomain): void
{
$this->translationDomain = $translationDomain;
}
}

View File

@@ -67,13 +67,19 @@ final class AnnotationExtractor implements ExtractorInterface
$parsed = $this->expressionLanguage->parse($arguments['exp'], ['object']);
$columns[$arguments['name']] = new ColumnDefinition(
$name = $arguments['name'];
$columns[$name] = new ColumnDefinition(
$arguments['label'],
$arguments['type'] ?? 'string',
function ($obj) use ($parsed) {
return $parsed->getNodes()->evaluate([], ['object' => $obj]);
}
);
if (\array_key_exists('translationDomain', $arguments) && \is_string($arguments['translationDomain'])) {
$columns[$name]->setTranslationDomain($arguments['translationDomain']);
}
}
foreach ($reflectionClass->getProperties() as $property) {
@@ -103,6 +109,10 @@ final class AnnotationExtractor implements ExtractorInterface
return $property->getValue($obj);
}
);
if (\array_key_exists('translationDomain', $arguments) && \is_string($arguments['translationDomain'])) {
$columns[$name]->setTranslationDomain($arguments['translationDomain']);
}
}
}
@@ -137,6 +147,10 @@ final class AnnotationExtractor implements ExtractorInterface
return $method->invoke($obj);
}
);
if (\array_key_exists('translationDomain', $arguments) && \is_string($arguments['translationDomain'])) {
$columns[$name]->setTranslationDomain($arguments['translationDomain']);
}
}
}

View File

@@ -66,7 +66,7 @@ class SpreadsheetExporter
$recordsHeaderRow = 1;
foreach ($columns as $settings) {
$sheet->setCellValue(CellAddress::fromColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow), $this->translator->trans($settings->getLabel()));
$sheet->setCellValue(CellAddress::fromColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow), $this->translator->trans($settings->getLabel(), [], $settings->getTranslationDomain()));
}
$entryHeaderRow = $recordsHeaderRow + 1;

View File

@@ -0,0 +1,36 @@
<?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\Form\Extension;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class HelpTranslationDomainExtension extends AbstractTypeExtension
{
public static function getExtendedTypes(): iterable
{
return [FormType::class];
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['help_translation_domain'] = $options['help_translation_domain'] ?? null;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefined(['help_translation_domain']);
$resolver->setAllowedTypes('help_translation_domain', ['string', 'null']);
$resolver->setDefault('help_translation_domain', null);
}
}

View File

@@ -23,6 +23,7 @@ final class CalendarTitlePatternType extends AbstractType
public const PATTERN_PROJECT = '{project}';
public const PATTERN_ACTIVITY = '{activity}';
public const PATTERN_DESCRIPTION = '{description}';
public const PATTERN_DURATION = '{duration}';
public const SPACER = ' - ';
public const PATTERN_ACTIVITY_DESCRIPTION = self::PATTERN_ACTIVITY . self::SPACER . self::PATTERN_DESCRIPTION;
public const PATTERN_PROJECT_DESCRIPTION = self::PATTERN_PROJECT . self::SPACER . self::PATTERN_DESCRIPTION;
@@ -39,6 +40,7 @@ final class CalendarTitlePatternType extends AbstractType
$project = $this->translator->trans('project');
$activity = $this->translator->trans('activity');
$description = $this->translator->trans('description');
$duration = $this->translator->trans('duration');
$resolver->setDefaults([
'label' => 'choice_pattern',
@@ -47,6 +49,7 @@ final class CalendarTitlePatternType extends AbstractType
$project => CalendarTitlePatternType::PATTERN_PROJECT,
$customer => CalendarTitlePatternType::PATTERN_CUSTOMER,
$description => CalendarTitlePatternType::PATTERN_DESCRIPTION,
$duration => CalendarTitlePatternType::PATTERN_DURATION,
$activity . self::SPACER . $description => CalendarTitlePatternType::PATTERN_ACTIVITY_DESCRIPTION,
$project . self::SPACER . $description => CalendarTitlePatternType::PATTERN_PROJECT_DESCRIPTION,
$customer . self::SPACER . $description => CalendarTitlePatternType::PATTERN_CUSTOMER_DESCRIPTION,

View File

@@ -15,6 +15,9 @@ use App\Repository\TagRepository;
use App\Utils\Color;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\Options;
@@ -25,6 +28,67 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
final class TagsSelectType extends AbstractType
{
public function __construct(private TagRepository $tagRepository)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($options) {
if (!$options['allow_create']) {
return;
}
$tagIds = $event->getData();
if (!\is_array($tagIds)) {
return;
}
$ids = array_filter($tagIds, function ($tagId) {
if (is_numeric($tagId)) {
return true;
}
return false;
});
// get the current tags and find the new ones that should be created
$tags = $this->tagRepository->findBy(['id' => $ids]);
$foundIds = [];
foreach ($tags as $tag) {
$foundIds[] = (string) $tag->getId();
}
$newData = [];
$newNames = [];
foreach ($tagIds as $tag) {
if (!\in_array($tag, $foundIds, true)) {
$newNames[] = $tag;
} else {
$newData[] = $tag;
}
}
// in case someone is using tags like "1234" this can interfere with the ID
$tags = $this->tagRepository->findTagsByName($newNames);
$foundTagNames = [];
foreach ($tags as $tag) {
$newData[] = (string) $tag->getId();
$foundTagNames[] = $tag->getName();
}
/** @var array<string> $newNames */
$newNames = array_diff($newNames, $foundTagNames);
foreach ($newNames as $name) {
$tag = new Tag();
$tag->setName($name);
$this->tagRepository->saveTag($tag);
$newData[] = $tag->getId();
}
$event->setData($newData);
}, 1000);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([

View File

@@ -16,8 +16,10 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class TagsType extends AbstractType
{
public function __construct(private AuthorizationCheckerInterface $auth, private TagRepository $repository)
{
public function __construct(
private AuthorizationCheckerInterface $auth,
private TagRepository $repository
) {
}
public function configureOptions(OptionsResolver $resolver): void

View File

@@ -52,8 +52,8 @@ final class TimePickerType extends AbstractType
// DateTimePickerType
if ($options['input'] === 'array' && \is_array($data)) {
$now = new \DateTime('now', new \DateTimeZone($options['model_timezone']));
$hour = $data['hour'] === '' ? 0 : (int) $data['hour'];
$minute = $data['minute'] === '' ? 0 : (int) $data['minute'];
$hour = $data['hour'] === '' || !is_numeric($data['hour']) ? 0 : (int) $data['hour'];
$minute = $data['minute'] === '' || !is_numeric($data['minute']) ? 0 : (int) $data['minute'];
$now->setTime($hour, $minute, 0);
$data = $now;
}

View File

@@ -22,6 +22,9 @@ final class TimesheetBillableType extends AbstractType
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'documentation' => [
'description' => 'Whether this item should be refundable (yes) or not (no) or if it should be calculated by inherited settings from customer, project and activity (auto).',
],
'label' => 'billable',
'choices' => [
'automatic' => Timesheet::BILLABLE_AUTOMATIC,

View File

@@ -40,7 +40,7 @@ abstract class AbstractCalculator
public function getVat(): float
{
return $this->model->getTemplate()->getVat();
return $this->model->getTemplate()->getVat() ?? 0.00;
}
public function getTax(): float

View File

@@ -174,7 +174,6 @@ class LdapManager
$this->hydrateUserWithAttributesMap($user, $ldapEntry, $attributeMap);
/** @var string|array|null $email */
$email = $user->getEmail();
if (null === $email) {
$user->setEmail($user->getUserIdentifier());

View File

@@ -18,7 +18,7 @@ abstract class AbstractPluginExtension extends Extension
{
$bundleConfig = [$this->getAlias() => $configs];
if ($container->hasParameter('kimai.bundles.config')) { // @phpstan-ignore-line
if ($container->hasParameter('kimai.bundles.config')) {
$config = $container->getParameter('kimai.bundles.config');
if (!\is_array($config)) {
throw new \Exception('Invalid bundle configuration registered for ' . $this->getAlias());

View File

@@ -11,8 +11,13 @@ namespace App\Reporting;
final class Report implements ReportInterface
{
public function __construct(private string $id, private string $route, private string $label, private string $reportIcon)
{
public function __construct(
private string $id,
private string $route,
private string $label,
private string $reportIcon,
private string $translationDomain = 'reporting'
) {
}
public function getRoute(): string
@@ -34,4 +39,9 @@ final class Report implements ReportInterface
{
return $this->reportIcon;
}
public function getTranslationDomain(): string
{
return $this->translationDomain;
}
}

View File

@@ -35,26 +35,6 @@ class ActivityRepository extends EntityRepository
{
use RepositorySearchTrait;
/**
* @param mixed $id
* @param null $lockMode
* @param null $lockVersion
* @return Activity|null
*/
public function find($id, $lockMode = null, $lockVersion = null): ?Activity
{
/** @var Activity|null $activity */
$activity = parent::find($id, $lockMode, $lockVersion);
if (null === $activity) {
return null;
}
$loader = new ActivityLoader($this->getEntityManager(), true);
$loader->loadResults([$activity]);
return $activity;
}
/**
* @param Project $project
* @return Activity[]

View File

@@ -35,26 +35,6 @@ class CustomerRepository extends EntityRepository
{
use RepositorySearchTrait;
/**
* @param mixed $id
* @param null $lockMode
* @param null $lockVersion
* @return Customer|null
*/
public function find($id, $lockMode = null, $lockVersion = null): ?Customer
{
/** @var Customer|null $customer */
$customer = parent::find($id, $lockMode, $lockVersion);
if (null === $customer) {
return null;
}
$loader = new CustomerLoader($this->getEntityManager(), true);
$loader->loadResults([$customer]);
return $customer;
}
/**
* @param int[] $customerIDs
* @return Customer[]

View File

@@ -32,6 +32,9 @@ final class ActivityLoader implements LoaderInterface
$ids = array_map(function ($activity) {
if ($activity instanceof Activity) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$activity->getName();
return $activity->getId();
}

View File

@@ -30,6 +30,9 @@ final class CustomerLoader implements LoaderInterface
$ids = array_map(function ($customer) {
if ($customer instanceof Customer) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$customer->getName();
return $customer->getId();
}

View File

@@ -29,6 +29,9 @@ final class InvoiceLoader implements LoaderInterface
$ids = array_map(function ($invoice) {
if ($invoice instanceof Invoice) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$invoice->getInvoiceNumber();
return $invoice->getId();
}

View File

@@ -31,6 +31,9 @@ final class ProjectLoader implements LoaderInterface
$ids = array_map(function ($project) {
if ($project instanceof Project) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$project->getName();
return $project->getId();
}

View File

@@ -29,6 +29,9 @@ final class TeamLoader implements LoaderInterface
$ids = array_map(function ($team) {
if ($team instanceof Team) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$team->getName();
return $team->getId();
}

View File

@@ -32,6 +32,9 @@ final class TimesheetLoader implements LoaderInterface
$ids = array_map(function ($timesheet) {
if ($timesheet instanceof Timesheet) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$timesheet->getType();
return $timesheet->getId();
}

View File

@@ -30,6 +30,9 @@ final class UserLoader implements LoaderInterface
$ids = array_map(function ($user) {
if ($user instanceof User) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$user->getDisplayName();
return $user->getId();
}

View File

@@ -38,26 +38,6 @@ class ProjectRepository extends EntityRepository
{
use RepositorySearchTrait;
/**
* @param mixed $id
* @param null $lockMode
* @param null $lockVersion
* @return Project|null
*/
public function find($id, $lockMode = null, $lockVersion = null): ?Project
{
/** @var Project|null $project */
$project = parent::find($id, $lockMode, $lockVersion);
if (null === $project) {
return null;
}
$loader = new ProjectLoader($this->getEntityManager(), true);
$loader->loadResults([$project]);
return $project;
}
/**
* @param int[] $projectIds
* @return Project[]

View File

@@ -64,7 +64,7 @@ trait RepositorySearchTrait
$c = 0;
foreach ($searchTerm->getSearchFields() as $metaName => $metaValue) {
$and = $qb->expr()->andX();
/** @var literal-string $alias */
/** @var non-falsy-string&literal-string $alias */
$alias = 'meta' . $a++;
$paramName = 'metaName' . $i++;
$paramValue = 'metaValue' . $c++;

View File

@@ -40,20 +40,6 @@ class TeamRepository extends EntityRepository
return $result;
}
public function find($id, $lockMode = null, $lockVersion = null): ?Team
{
/** @var Team|null $team */
$team = parent::find($id, $lockMode, $lockVersion);
if (null === $team) {
return null;
}
$loader = new TeamLoader($this->getEntityManager());
$loader->loadResults([$team]);
return $team;
}
/**
* @param int[] $teamIds
* @return Team[]

View File

@@ -78,26 +78,6 @@ class TimesheetRepository extends EntityRepository
return $qb->getQuery()->getOneOrNullResult();
}
/**
* @param mixed $id
* @param null $lockMode
* @param null $lockVersion
* @return Timesheet|null
*/
public function find($id, $lockMode = null, $lockVersion = null): ?Timesheet
{
/** @var Timesheet|null $timesheet */
$timesheet = parent::find($id, $lockMode, $lockVersion);
if (null === $timesheet) {
return null;
}
$loader = new TimesheetLoader($this->getEntityManager());
$loader->loadResults([$timesheet]);
return $timesheet;
}
public function delete(Timesheet $timesheet): void
{
$entityManager = $this->getEntityManager();

View File

@@ -46,8 +46,11 @@ trait TwigRendererTrait
{
/** @var TranslationExtension $extension */
$extension = $twig->getExtension(TranslationExtension::class);
/** @var LocaleAwareInterface $translator */
$translator = $extension->getTranslator();
if (!$translator instanceof LocaleAwareInterface) {
throw new \Exception('Translator is expected to be of type LocaleAwareInterface');
}
$previous = $translator->getLocale();
$translator->setLocale($language);

View File

@@ -28,6 +28,7 @@ final class MenuItemModel implements MenuItemInterface
private bool $divider = false;
private bool $lastWasDivider = false;
private bool $expanded = false;
private string $translationDomain = 'messages';
public function __construct(
string $id,
@@ -297,4 +298,14 @@ final class MenuItemModel implements MenuItemInterface
{
$this->expanded = $expanded;
}
public function getTranslationDomain(): string
{
return $this->translationDomain;
}
public function setTranslationDomain(string $translationDomain): void
{
$this->translationDomain = $translationDomain;
}
}

View File

@@ -14,6 +14,7 @@ final class PageSetup
private ?string $help = null;
private ?string $actionName = null;
private string $actionView = 'index';
private string $translationDomain = 'messages';
private array $actionPayload = [];
private ?DataTable $dataTable = null;
@@ -90,4 +91,14 @@ final class PageSetup
{
return \in_array($this->actionView, ['detail', 'custom', 'table']);
}
public function getTranslationDomain(): string
{
return $this->translationDomain;
}
public function setTranslationDomain(string $translationDomain): void
{
$this->translationDomain = $translationDomain;
}
}

View File

@@ -95,11 +95,11 @@ final class TimesheetBudgetUsedValidator extends ConstraintValidator
$timeRate = $this->rateService->calculate($timesheet);
$rate = $timeRate->getRate();
$activityDuration = $duration;
$activityDuration = $duration ?? 0;
$activityRate = $rate;
$projectDuration = $duration;
$projectDuration = $duration ?? 0;
$projectRate = $rate;
$customerDuration = $duration;
$customerDuration = $duration ?? 0;
$customerRate = $rate;
$monthWasChanged = false;

View File

@@ -141,9 +141,6 @@
"gedmo/doctrine-extensions": {
"version": "v2.4.36"
},
"jeroendesloovere/vcard": {
"version": "v1.7.3"
},
"jms/metadata": {
"version": "1.6.0"
},

View File

@@ -165,7 +165,7 @@
{% block page_title %}
{%- if page_setup is defined -%}
{{ page_setup.title|trans }}
{{ page_setup.title|trans({}, page_setup.translationDomain) }}
{%- else -%}
{{ get_title() }}
{%- endif -%}

View File

@@ -17,7 +17,7 @@
{% endif %}
{% for source in dragAndDrop|filter(s => s.entries|length > 0) %}
{% embed '@theme/embeds/card.html.twig' %}
{% block box_title %}{{ source.title|trans }}{% endblock %}
{% block box_title %}{{ source.title|trans({}, source.translationDomain) }}{% endblock %}
{% block box_body_class %}drag-and-drop-source p-0{% endblock %}
{% block box_body %}
<div class="list-group list-group-flush external-events" data-method="{{ source.method }}" data-route="{{ path(source.route, source.routeParams) }}" data-route-replacer="{{ source.routeReplacer|json_encode|e('html_attr') }}">

View File

@@ -13,7 +13,6 @@
{% endblock %}
{% block box_tools %}
{% if can_edit %}
{{ widgets.card_tool_button('vcard', {'title': 'vcard', 'translation_domain': 'actions', 'icon': 'far fa-address-card', 'url': path('customer_vcard', {'id': customer.id})}) }}
{{ widgets.card_tool_button('edit', {'class': 'modal-ajax-form open-edit', 'title': 'action.edit', 'url': path('admin_customer_edit', {'id': customer.id})}) }}
{% endif %}
{% endblock %}

View File

@@ -350,3 +350,23 @@
{{ form_row(meta.value, {'row_attr': {'class': 'mb-3 ' ~ form.parent.vars.name ~ '_row_' ~ form.vars.name ~ '_' ~ meta.vars.name}}) }}
{% endfor %}
{%- endblock meta_fields_collection_widget %}
{% block form_help_content -%}
{%- if help_translation_domain is defined and help_translation_domain is not null -%}
{%- set translation_domain = help_translation_domain -%}
{%- endif -%}
{# following copied from form_div_layout.html.twig #}
{%- if translation_domain is same as(false) -%}
{%- if help_html is same as(false) -%}
{{- help -}}
{%- else -%}
{{- help|raw -}}
{%- endif -%}
{%- else -%}
{%- if help_html is same as(false) -%}
{{- help|trans(help_translation_parameters, translation_domain) -}}
{%- else -%}
{{- help|trans(help_translation_parameters, translation_domain)|raw -}}
{%- endif -%}
{%- endif -%}
{%- endblock form_help_content %}

View File

@@ -17,7 +17,7 @@
<div class="col-auto">
<span class="avatar"><i class="icon {{ report.reportIcon|icon(false, report.reportIcon) }}" style="color: {{ null|colorize(report.reportIcon) }}"></i></span>
</div>
<div class="col p-2">{{ report.label|trans({}, 'reporting') }}</div>
<div class="col p-2">{{ report.label|trans({}, report.translationDomain) }}</div>
</div>
</div>
</a>

View File

@@ -5,10 +5,17 @@
{% block breadcrumb %}
{% from "macros/breadcrumb.html.twig" import breadcrumb %}
{{ breadcrumb({
('menu.reporting'|trans): path('reporting'),
(report_title|trans({}, 'reporting')): '#'
}) }}
{% if page_setup is defined %}
{{ breadcrumb({
('menu.reporting'|trans): path('reporting'),
(page_setup.title|trans({}, page_setup.translationDomain)): '#'
}) }}
{% else %}
{{ breadcrumb({
('menu.reporting'|trans): path('reporting'),
(report_title|trans({}, 'reporting')): '#'
}) }}
{% endif %}
{% endblock %}
{% block main %}

View File

@@ -181,7 +181,6 @@ class ActionsControllerTest extends APIControllerBaseTest
'details',
'edit',
'permissions',
'vcard',
'divider0',
'filter',
'divider1',

View File

@@ -46,8 +46,8 @@ class SecurityControllerTest extends ControllerBaseTest
$content = $response->getContent();
$this->assertStringContainsString('<title>Kimai Time Tracking</title>', $content);
$this->assertStringContainsString('<form action="/en/login_check" method="post"', $content);
$this->assertStringContainsString('<input type="text" name="_username"', $content);
$this->assertStringContainsString('<input name="_password" type="password"', $content);
$this->assertStringContainsString('<input type="text" id="username" name="_username"', $content);
$this->assertStringContainsString('<input id="password" name="_password" type="password"', $content);
$this->assertStringContainsString('">Login</button>', $content);
$this->assertStringContainsString('<input type="hidden" name="_csrf_token" value="', $content);
$this->assertStringNotContainsString('<a href="/en/register/"', $content);

View File

@@ -64,6 +64,11 @@ class TestDragAndDropSource implements DragAndDropSource
return $this->title;
}
public function getTranslationDomain(): string
{
return 'messages';
}
public function getRoute(): string
{
return '';

View File

@@ -14,14 +14,14 @@ use App\Export\Annotation as Exporter;
#[Exporter\Order(['a-time', 'publicProperty', 'a-date', 'something', 'privateProperty'])]
#[Exporter\Expose(name: 'accessor', label: 'accessor', exp: 'object.accessorMethod()')]
#[Exporter\Expose(name: 'a-date', label: 'type-date', exp: 'object.getDateTime()', type: 'date')]
#[Exporter\Expose(name: 'a-time', label: 'type-time', exp: 'object.getDateTime()', type: 'time')]
#[Exporter\Expose(name: 'a-time', label: 'type-time', exp: 'object.getDateTime()', type: 'time', translationDomain: 'foo')]
class DemoFull
{
#[Exporter\Expose(label: 'Public-Property', type: 'string')]
public string $publicProperty = 'public-property';
#[Exporter\Expose(name: 'fake-name', label: 'Protected-Property', type: 'boolean')]
protected bool $protectedProperty = false;
#[Exporter\Expose(label: 'Private-Property', type: 'integer')]
#[Exporter\Expose(label: 'Private-Property', type: 'integer', translationDomain: 'test')]
private int $privateProperty = 123; // @phpstan-ignore-line
#[Exporter\Expose(label: 'Public-Method')]
@@ -48,7 +48,7 @@ class DemoFull
}
// @phpstan-ignore-next-line
#[Exporter\Expose(name: 'fake-method', label: 'Private-Method', type: 'boolean')]
#[Exporter\Expose(name: 'fake-method', label: 'Private-Method', type: 'boolean', translationDomain: 'bar')]
private function privateMethod(): bool
{
return true;

View File

@@ -42,16 +42,16 @@ class AnnotationExtractorTest extends TestCase
}
$expected = [
['type-time', 'time', new \DateTime()],
['Public-Property', 'string', 'public-property'],
['type-date', 'date', new \DateTime()],
['Private-Property', 'integer', 123],
['accessor', 'string', 'accessor-method'],
['Protected-Property', 'boolean', false],
['Public-Method', 'string', 'public-method'],
['Protected-Method', 'datetime', new \DateTime()],
['duration', 'duration', 12345],
['Private-Method', 'boolean', true],
['type-time', 'time', new \DateTime(), 'foo'],
['Public-Property', 'string', 'public-property', 'messages'],
['type-date', 'date', new \DateTime(), 'messages'],
['Private-Property', 'integer', 123, 'test'],
['accessor', 'string', 'accessor-method', 'messages'],
['Protected-Property', 'boolean', false, 'messages'],
['Public-Method', 'string', 'public-method', 'messages'],
['Protected-Method', 'datetime', new \DateTime(), 'messages'],
['duration', 'duration', 12345, 'messages'],
['Private-Method', 'boolean', true, 'bar'],
];
$i = 0;
@@ -66,6 +66,7 @@ class AnnotationExtractorTest extends TestCase
if (\in_array(get_debug_type($result), ['string', 'int', 'bool', 'float'])) {
self::assertEquals($item[2], $result);
}
self::assertEquals($item[3], $column->getTranslationDomain(), 'Failed translation domain for: ' . $item[0]);
}
}

View File

@@ -26,8 +26,10 @@ class ReportTest extends TestCase
self::assertEquals('route', $report->getRoute());
self::assertEquals('label', $report->getLabel());
self::assertEquals('reporting', $report->getReportIcon());
self::assertEquals('reporting', $report->getTranslationDomain());
$report = new Report('id', 'route', 'label', 'foo');
$report = new Report('id', 'route', 'label', 'foo', 'bar');
self::assertEquals('foo', $report->getReportIcon());
self::assertEquals('bar', $report->getTranslationDomain());
}
}

View File

@@ -22,6 +22,10 @@ class MenuItemModelTest extends TestCase
$sut = new MenuItemModel('test', 'foo', 'bar');
self::assertEquals('foo', $sut->getLabel());
self::assertEquals('messages', $sut->getTranslationDomain());
$sut->setTranslationDomain('foo');
self::assertEquals('foo', $sut->getTranslationDomain());
self::assertFalse($sut->isChildRoute('blub'));
self::assertFalse($sut->isChildRoute('bla'));

View File

@@ -26,6 +26,7 @@ class PageSetupTest extends TestCase
self::assertNull($sut->getActionName());
self::assertEquals([], $sut->getActionPayload());
self::assertEquals('index', $sut->getActionView());
self::assertEquals('messages', $sut->getTranslationDomain());
self::assertNull($sut->getHelp());
self::assertNull($sut->getDataTable());
}
@@ -39,7 +40,9 @@ class PageSetupTest extends TestCase
$sut->setHelp('kjhgkjhgkjhg');
$sut->setActionView('custom');
$sut->setActionPayload(['foo' => 'bar']);
$sut->setTranslationDomain('footuluuu');
self::assertEquals('footuluuu', $sut->getTranslationDomain());
self::assertEquals('foo-bar', $sut->getTitle());
self::assertEquals('some-action', $sut->getActionName());
self::assertEquals(['foo' => 'bar'], $sut->getActionPayload());

View File

@@ -1153,6 +1153,18 @@
<source>status.canceled</source>
<target>Storniert</target>
</trans-unit>
<trans-unit id="Zngu6M9" resname="status.planned">
<source>status.planned</source>
<target>Geplant</target>
</trans-unit>
<trans-unit id="m8V2yFG" resname="status.progress">
<source>status.progress</source>
<target>In Bearbeitung</target>
</trans-unit>
<trans-unit id="HF70hZq" resname="status.closed">
<source>status.closed</source>
<target>Erledigt</target>
</trans-unit>
<trans-unit id="crrlEUA" resname="preview.skipped_rows">
<source>preview.skipped_rows</source>
<target>Überspringe Vorschau von %rows% weiteren Zeilen …</target>

View File

@@ -1153,6 +1153,18 @@
<source>status.canceled</source>
<target>Canceled</target>
</trans-unit>
<trans-unit id="Zngu6M9" resname="status.planned">
<source>status.planned</source>
<target>Planned</target>
</trans-unit>
<trans-unit id="m8V2yFG" resname="status.progress">
<source>status.progress</source>
<target>In progress</target>
</trans-unit>
<trans-unit id="HF70hZq" resname="status.closed">
<source>status.closed</source>
<target>Completed</target>
</trans-unit>
<trans-unit id="crrlEUA" resname="preview.skipped_rows">
<source>Skipped preview of %rows% more rows …</source>
<target state="translated">Skipped preview of %rows% more rows …</target>

View File

@@ -880,9 +880,18 @@
<source>status.paid</source>
<target state="translated">Maksettu</target>
</trans-unit>
<!--
Export
-->
<trans-unit id="Zngu6M9" resname="status.planned">
<source>status.planned</source>
<target>Suunniteltu</target>
</trans-unit>
<trans-unit id="m8V2yFG" resname="status.progress">
<source>status.progress</source>
<target>Kesken</target>
</trans-unit>
<trans-unit id="HF70hZq" resname="status.closed">
<source>status.closed</source>
<target>Suoritettu</target>
</trans-unit>
<trans-unit id="1GruCMx" resname="export">
<source>export</source>
<target state="translated">Vie</target>

View File

@@ -894,6 +894,18 @@
<source>status.new</source>
<target>Nouveau</target>
</trans-unit>
<trans-unit id="Zngu6M9" resname="status.planned">
<source>status.planned</source>
<target>Planifiée</target>
</trans-unit>
<trans-unit id="m8V2yFG" resname="status.progress">
<source>status.progress</source>
<target>En progrès</target>
</trans-unit>
<trans-unit id="HF70hZq" resname="status.closed">
<source>status.closed</source>
<target>Fermée</target>
</trans-unit>
<trans-unit id="BzwWNMS" resname="status">
<source>status</source>
<target>État</target>

122
translations/tasks.de.xlf Normal file
View File

@@ -0,0 +1,122 @@
<?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="de" datatype="plaintext" original="tasks.en.xlf">
<body>
<!-- Reports -->
<trans-unit id="tLV1hoP" resname="report_tasks_assigned">
<source>report_tasks_assigned</source>
<target>Meine Aufgaben</target>
</trans-unit>
<trans-unit id="8c0YWZm" resname="report_tasks_teams">
<source>report_tasks_teams</source>
<target>Team Aufgaben</target>
</trans-unit>
<!-- Actions -->
<trans-unit id="XjAwhoV" resname="task.start">
<source>task.start</source>
<target>Bearbeitung beginnen</target>
</trans-unit>
<trans-unit id="XD7TZh7" resname="task.stop">
<source>task.stop</source>
<target>Bearbeitung pausieren</target>
</trans-unit>
<trans-unit id="sG_9DvK" resname="task.assign">
<source>task.assign</source>
<target>Aufgabe mir zuweisen</target>
</trans-unit>
<trans-unit id="qLewfPg" resname="task.unassign">
<source>task.unassign</source>
<target>Aufgabe freigeben</target>
</trans-unit>
<trans-unit id="F1_rg1R" resname="task.reopen">
<source>task.reopen</source>
<target>Aufgabe erneut öffnen</target>
</trans-unit>
<trans-unit id="duBK63z" resname="task.close">
<source>task.close</source>
<target>Aufgabe abschließen</target>
</trans-unit>
<trans-unit id="t.y642H" resname="task.create">
<source>task.create</source>
<target>Aufgabe erstellen</target>
</trans-unit>
<!-- Application -->
<trans-unit id="s6YOYaU" resname="Tasks">
<source>Tasks</source>
<target>Aufgaben</target>
</trans-unit>
<trans-unit id="7f4C38O" resname="tasks.pending">
<source>tasks.pending</source>
<target>Geplante Aufgaben</target>
</trans-unit>
<trans-unit id="rLKr7Zu" resname="tasks.my">
<source>tasks.my</source>
<target>Meine Aufgaben</target>
</trans-unit>
<trans-unit id="bnCqljW" resname="task.delete">
<source>task.delete</source>
<target>Aufgabe löschen? Aufgezeichnete Zeiten werden nicht gelöscht!</target>
</trans-unit>
<trans-unit id="FhEb.Hn" resname="task.help.title">
<source>task.help.title</source>
<target>Eine kurze Beschreibung der Aufgabe</target>
</trans-unit>
<trans-unit id="WCaBimL" resname="task.help.end">
<source>task.help.end</source>
<target>Aufgabe muss bis zu diesem Datum abgeschlossen sein</target>
</trans-unit>
<trans-unit id="taZkyYV" resname="task.help.user">
<source>task.help.user</source>
<target>Diese Aufgabe einem Benutzer zuordnen</target>
</trans-unit>
<trans-unit id="QF_bQPv" resname="task.help.team">
<source>task.help.team</source>
<target>Diese Aufgabe einem Team zuordnen</target>
</trans-unit>
<trans-unit id="kMOottI" resname="task.widget_rows">
<source>task.widget_rows</source>
<target>Anzahl Aufgaben pro Seite im Dashboard</target>
</trans-unit>
<trans-unit id="tt.jOEH" resname="estimation">
<source>estimation</source>
<target>Zeitschätzung</target>
</trans-unit>
<trans-unit id="ZCSPtzc" resname="task.help.estimation">
<source>task.help.estimation</source>
<target>Geschätzte Dauer zur Erledigung der Aufgabe in hh:mm</target>
</trans-unit>
<trans-unit id="SzbkpWy" resname="task.help.description">
<source>task.help.description</source>
<target>Diese Beschreibung wird in jeden erfassten Zeiteintrag kopiert</target>
</trans-unit>
<trans-unit id="zJvuPBv" resname="task.help.tags">
<source>task.help.tags</source>
<target>Diese Schlagworte werden in jeden erfassten Zeiteintrag kopiert</target>
</trans-unit>
<trans-unit id="agwwK.J" resname="task_no_estimation">
<source>task_no_estimation</source>
<target>Ohne Zeitschätzung</target>
</trans-unit>
<trans-unit id="kgA8TVH" resname="task_with_estimation">
<source>task_with_estimation</source>
<target>Mit Zeitschätzung</target>
</trans-unit>
<trans-unit id="H.zMNfb" resname="task_started">
<source>task_started</source>
<target>Arbeit begonnen</target>
</trans-unit>
<trans-unit id="cu1.2JS" resname="task_not_started">
<source>task_not_started</source>
<target>Nicht gestartet</target>
</trans-unit>
<trans-unit id="ZwdUYYa" resname="task_overdue">
<source>task_overdue</source>
<target>Überfällig</target>
</trans-unit>
<trans-unit id="1ltxm9V" resname="task_todo">
<source>task_todo</source>
<target>Aufgabenbeschreibung</target>
</trans-unit>
</body>
</file>
</xliff>

122
translations/tasks.en.xlf Normal file
View File

@@ -0,0 +1,122 @@
<?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="en" datatype="plaintext" original="tasks.en.xlf">
<body>
<!-- Reports -->
<trans-unit id="tLV1hoP" resname="report_tasks_assigned">
<source>report_tasks_assigned</source>
<target>My tasks</target>
</trans-unit>
<trans-unit id="8c0YWZm" resname="report_tasks_teams">
<source>report_tasks_teams</source>
<target>Team tasks</target>
</trans-unit>
<!-- Actions -->
<trans-unit id="XjAwhoV" resname="task.start">
<source>task.start</source>
<target>Start processing</target>
</trans-unit>
<trans-unit id="XD7TZh7" resname="task.stop">
<source>task.stop</source>
<target>Pause processing</target>
</trans-unit>
<trans-unit id="sG_9DvK" resname="task.assign">
<source>task.assign</source>
<target>Assign to me</target>
</trans-unit>
<trans-unit id="qLewfPg" resname="task.unassign">
<source>task.unassign</source>
<target>Release task</target>
</trans-unit>
<trans-unit id="F1_rg1R" resname="task.reopen">
<source>task.reopen</source>
<target>Re-open task</target>
</trans-unit>
<trans-unit id="duBK63z" resname="task.close">
<source>task.close</source>
<target>Complete task</target>
</trans-unit>
<trans-unit id="t.y642H" resname="task.create">
<source>task.create</source>
<target>Create task</target>
</trans-unit>
<!-- Application -->
<trans-unit id="s6YOYaU" resname="Tasks">
<source>Tasks</source>
<target>Tasks</target>
</trans-unit>
<trans-unit id="7f4C38O" resname="tasks.pending">
<source>tasks.pending</source>
<target>Pending tasks</target>
</trans-unit>
<trans-unit id="rLKr7Zu" resname="tasks.my">
<source>tasks.my</source>
<target>My tasks</target>
</trans-unit>
<trans-unit id="bnCqljW" resname="task.delete">
<source>task.delete</source>
<target>Delete task? Time records will not be deleted!</target>
</trans-unit>
<trans-unit id="FhEb.Hn" resname="task.help.title">
<source>task.help.title</source>
<target>A short description of the task</target>
</trans-unit>
<trans-unit id="WCaBimL" resname="task.help.end">
<source>task.help.end</source>
<target>Task must be completed until this date</target>
</trans-unit>
<trans-unit id="taZkyYV" resname="task.help.user">
<source>task.help.user</source>
<target>Assign this task to a user</target>
</trans-unit>
<trans-unit id="QF_bQPv" resname="task.help.team">
<source>task.help.team</source>
<target>Assign this task to a team</target>
</trans-unit>
<trans-unit id="kMOottI" resname="task.widget_rows">
<source>task.widget_rows</source>
<target>Amount of tasks per page in dashboard widget</target>
</trans-unit>
<trans-unit id="tt.jOEH" resname="estimation">
<source>estimation</source>
<target>Time estimation</target>
</trans-unit>
<trans-unit id="ZCSPtzc" resname="task.help.estimation">
<source>task.help.estimation</source>
<target>Estimated time needed to complete the task in hh:mm</target>
</trans-unit>
<trans-unit id="SzbkpWy" resname="task.help.description">
<source>task.help.description</source>
<target>This description will be copied into each timesheet record</target>
</trans-unit>
<trans-unit id="zJvuPBv" resname="task.help.tags">
<source>task.help.tags</source>
<target>These tags will be copied into each timesheet record</target>
</trans-unit>
<trans-unit id="agwwK.J" resname="task_no_estimation">
<source>task_no_estimation</source>
<target>Without estimation</target>
</trans-unit>
<trans-unit id="kgA8TVH" resname="task_with_estimation">
<source>task_with_estimation</source>
<target>With estimation</target>
</trans-unit>
<trans-unit id="H.zMNfb" resname="task_started">
<source>task_started</source>
<target>Work started</target>
</trans-unit>
<trans-unit id="cu1.2JS" resname="task_not_started">
<source>task_not_started</source>
<target>Not started</target>
</trans-unit>
<trans-unit id="ZwdUYYa" resname="task_overdue">
<source>task_overdue</source>
<target>Overdue</target>
</trans-unit>
<trans-unit id="1ltxm9V" resname="task_todo">
<source>task_todo</source>
<target>Task description</target>
</trans-unit>
</body>
</file>
</xliff>

118
translations/tasks.fi.xlf Normal file
View File

@@ -0,0 +1,118 @@
<?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="fi" datatype="plaintext" original="tasks.en.xlf">
<body>
<!-- Reports -->
<trans-unit id="tLV1hoP" resname="report_tasks_assigned">
<source>report_tasks_assigned</source>
<target>Tehtäväni</target>
</trans-unit>
<trans-unit id="8c0YWZm" resname="report_tasks_teams">
<source>report_tasks_teams</source>
<target>Tiimin tehtävät</target>
</trans-unit>
<!-- Actions -->
<trans-unit id="XjAwhoV" resname="task.start">
<source>task.start</source>
<target>Aloita tehtävä</target>
</trans-unit>
<trans-unit id="XD7TZh7" resname="task.stop">
<source>task.stop</source>
<target>Keskeytä tehtävä</target>
</trans-unit>
<trans-unit id="sG_9DvK" resname="task.assign">
<source>task.assign</source>
<target>Aseta minulle</target>
</trans-unit>
<trans-unit id="qLewfPg" resname="task.unassign">
<source>task.unassign</source>
<target>Julkaise tehtävä</target>
</trans-unit>
<trans-unit id="F1_rg1R" resname="task.reopen">
<source>task.reopen</source>
<target>Avaa tehtävä uudelleen</target>
</trans-unit>
<trans-unit id="duBK63z" resname="task.close">
<source>task.close</source>
<target>Merkitse tehtävä suoritetuksi</target>
</trans-unit>
<trans-unit id="t.y642H" resname="task.create">
<source>task.create</source>
<target>Luo tehtävä</target>
</trans-unit>
<!-- Application -->
<trans-unit id="s6YOYaU" resname="Tasks">
<source>Tasks</source>
<target>Tehtävien</target>
</trans-unit>
<trans-unit id="7f4C38O" resname="tasks.pending">
<source>tasks.pending</source>
<target>Odottavat tehtävä</target>
</trans-unit>
<trans-unit id="rLKr7Zu" resname="tasks.my">
<source>tasks.my</source>
<target>Tehtäväni</target>
</trans-unit>
<trans-unit id="bnCqljW" resname="task.delete">
<source>task.delete</source>
<target>Poista tehtävä? Työaikoja ei poisteta!</target>
</trans-unit>
<trans-unit id="FhEb.Hn" resname="task.help.title">
<source>task.help.title</source>
<target>Lyhyt kuvaus tehtävälle</target>
</trans-unit>
<trans-unit id="WCaBimL" resname="task.help.end">
<source>task.help.end</source>
<target>Tehtävä tulee olla suoritettuna</target>
</trans-unit>
<trans-unit id="taZkyYV" resname="task.help.user">
<source>task.help.user</source>
<target>Aseta tehtävä käyttäjälle</target>
</trans-unit>
<trans-unit id="QF_bQPv" resname="task.help.team">
<source>task.help.team</source>
<target>Aseta tehtävä tiimille</target>
</trans-unit>
<trans-unit id="kMOottI" resname="task.widget_rows">
<source>task.widget_rows</source>
<target>Tehtävien määrä per sivu ohjauspaneelin widgetissä</target>
</trans-unit>
<trans-unit id="tt.jOEH" resname="estimation">
<source>estimation</source>
<target>Aika-arvio</target>
</trans-unit>
<trans-unit id="ZCSPtzc" resname="task.help.estimation">
<source>task.help.estimation</source>
<target>Arvioitu aika tehtävän suorittamiseen muodossa hh:mm</target>
</trans-unit>
<trans-unit id="SzbkpWy" resname="task.help.description">
<source>task.help.description</source>
<target>Tämä kuvaus kopioidaan jokaiseen työaikakirjaukseen</target>
</trans-unit>
<trans-unit id="zJvuPBv" resname="task.help.tags">
<source>task.help.tags</source>
<target>Nämä tunnisteet kopioidaan jokaiseen työaikakirjaukseen</target>
</trans-unit>
<trans-unit id="agwwK.J" resname="task_no_estimation">
<source>task_no_estimation</source>
<target>Ilman aika-arviota</target>
</trans-unit>
<trans-unit id="kgA8TVH" resname="task_with_estimation">
<source>task_with_estimation</source>
<target>Aika-arviolla</target>
</trans-unit>
<trans-unit id="H.zMNfb" resname="task_started">
<source>task_started</source>
<target>Työ aloitettu</target>
</trans-unit>
<trans-unit id="cu1.2JS" resname="task_not_started">
<source>task_not_started</source>
<target>Ei aloitettu</target>
</trans-unit>
<trans-unit id="ZwdUYYa" resname="task_overdue">
<source>task_overdue</source>
<target>Myöhässä</target>
</trans-unit>
</body>
</file>
</xliff>

126
translations/tasks.fr.xlf Normal file
View File

@@ -0,0 +1,126 @@
<?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="fr" datatype="plaintext" original="tasks.en.xlf">
<body>
<!-- Reports -->
<trans-unit id="tLV1hoP" resname="report_tasks_assigned">
<source>report_tasks_assigned</source>
<target>Mes tâches</target>
</trans-unit>
<trans-unit id="8c0YWZm" resname="report_tasks_teams">
<source>report_tasks_teams</source>
<target>Tâches de l'équipe</target>
</trans-unit>
<!-- Actions -->
<trans-unit id="XjAwhoV" resname="task.start">
<source>task.start</source>
<target>Commencer le travail</target>
</trans-unit>
<trans-unit id="XD7TZh7" resname="task.stop">
<source>task.stop</source>
<target>Mettre le travail en pause</target>
</trans-unit>
<trans-unit id="sG_9DvK" resname="task.assign">
<source>task.assign</source>
<target>M'assigner la tâche</target>
</trans-unit>
<trans-unit id="qLewfPg" resname="task.unassign">
<source>task.unassign</source>
<target>Libérer la tâche</target>
</trans-unit>
<trans-unit id="F1_rg1R" resname="task.reopen">
<source>task.reopen</source>
<target>Réouvrir la tâche</target>
</trans-unit>
<trans-unit id="duBK63z" resname="task.close">
<source>task.close</source>
<target>Clore la tâche</target>
</trans-unit>
<trans-unit id="t.y642H" resname="task.create">
<source>task.create</source>
<target>Créer une tâche</target>
</trans-unit>
<!-- Application -->
<trans-unit id="s6YOYaU" resname="Tasks">
<source>Tasks</source>
<target>Tâches</target>
</trans-unit>
<trans-unit id="7f4C38O" resname="tasks.pending">
<source>tasks.pending</source>
<target>tâche plainifiée</target>
</trans-unit>
<trans-unit id="rLKr7Zu" resname="tasks.my">
<source>tasks.my</source>
<target>Mes tâches</target>
</trans-unit>
<trans-unit id="bnCqljW" resname="task.delete">
<source>task.delete</source>
<target>Effacer la tâche? Les temps assignés ne seront pas supprimés!</target>
</trans-unit>
<trans-unit id="ODW2.AZ" resname="quick_filters">
<source>quick_filters</source>
<target>Choix rapide</target>
</trans-unit>
<trans-unit id="FhEb.Hn" resname="task.help.title">
<source>task.help.title</source>
<target>Une description courte de la tâche</target>
</trans-unit>
<trans-unit id="WCaBimL" resname="task.help.end">
<source>task.help.end</source>
<target>La tâche doit être terminée jusqu'à cette date</target>
</trans-unit>
<trans-unit id="taZkyYV" resname="task.help.user">
<source>task.help.user</source>
<target>Assigner cette tâche à un utilisateur</target>
</trans-unit>
<trans-unit id="QF_bQPv" resname="task.help.team">
<source>task.help.team</source>
<target>Assigner cette tâche à une équipe</target>
</trans-unit>
<trans-unit id="kMOottI" resname="task.widget_rows">
<source>task.widget_rows</source>
<target>Nombre de tâches par page dans le Dashboard</target>
</trans-unit>
<trans-unit id="tt.jOEH" resname="estimation">
<source>estimation</source>
<target>Estimation du temps</target>
</trans-unit>
<trans-unit id="ZCSPtzc" resname="task.help.estimation">
<source>task.help.estimation</source>
<target>Temps estimé pour terminer la tâche en hh:mm</target>
</trans-unit>
<trans-unit id="SzbkpWy" resname="task.help.description">
<source>task.help.description</source>
<target>Cette description sera copiée dans chaque fiche de temps</target>
</trans-unit>
<trans-unit id="zJvuPBv" resname="task.help.tags">
<source>task.help.tags</source>
<target>Ce tag sera copié dans chaque fiche de temps</target>
</trans-unit>
<trans-unit id="agwwK.J" resname="task_no_estimation">
<source>task_no_estimation</source>
<target>Sans estimation de temps</target>
</trans-unit>
<trans-unit id="kgA8TVH" resname="task_with_estimation">
<source>task_with_estimation</source>
<target>Avec estimation de temps</target>
</trans-unit>
<trans-unit id="H.zMNfb" resname="task_started">
<source>task_started</source>
<target>Commencer le travail</target>
</trans-unit>
<trans-unit id="cu1.2JS" resname="task_not_started">
<source>task_not_started</source>
<target>Non commencé</target>
</trans-unit>
<trans-unit id="ZwdUYYa" resname="task_overdue">
<source>task_overdue</source>
<target>Périmée</target>
</trans-unit>
<trans-unit id="1ltxm9V" resname="task_todo">
<source>task_todo</source>
<target>Description de la tâche</target>
</trans-unit>
</body>
</file>
</xliff>