mirror of
https://github.com/kevinpapst/kimai2.git
synced 2025-03-17 06:22:38 +00:00
automatic billable calculation (#3200)
This commit is contained in:
parent
f7480902b4
commit
30c7782d6e
34 changed files with 409 additions and 53 deletions
config/packages
src
API
Configuration
Controller
Entity
Form
Migrations
Timesheet
Voter
templates
activity
customer
project
timesheet
tests
API
Controller
Entity
Export/Spreadsheet
translations
|
@ -101,6 +101,7 @@ kimai:
|
|||
RATE: ['view_rate_own_timesheet','edit_rate_own_timesheet']
|
||||
RATE_OTHER: ['view_rate_other_timesheet','edit_rate_other_timesheet']
|
||||
EXPORT: ['create_export','edit_export_own_timesheet','edit_export_other_timesheet']
|
||||
BILLABLE: ['edit_billable_own_timesheet','edit_billable_other_timesheet']
|
||||
TEAMS: ['view_team','create_team','edit_team','delete_team']
|
||||
LOCKDOWN: ['lockdown_grace_timesheet','lockdown_override_timesheet']
|
||||
REPORTING: ['view_reporting','view_other_reporting']
|
||||
|
@ -111,9 +112,9 @@ kimai:
|
|||
SINGLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template','view_all_data']
|
||||
# link above sets to one complete set for each user role
|
||||
ROLE_USER: ['@TIMESHEET','@PROFILE','@REPORTING','@SINGLE_USER']
|
||||
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@REPORTING','@SINGLE_TEAMLEAD']
|
||||
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@LOCKDOWN','@REPORTING','@SINGLE_ADMIN']
|
||||
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@LOCKDOWN','@REPORTING','@SINGLE_SUPER_ADMIN']
|
||||
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@BILLABLE','@TAGS','@REPORTING','@SINGLE_TEAMLEAD']
|
||||
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@BILLABLE','@TAGS','@LOCKDOWN','@REPORTING','@SINGLE_ADMIN']
|
||||
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@BILLABLE','@TAGS','@LOCKDOWN','@REPORTING','@SINGLE_SUPER_ADMIN']
|
||||
# mapping "sets" or permissions to user roles ("role name" = [array of "set names"])
|
||||
maps:
|
||||
ROLE_USER: ['ROLE_USER']
|
||||
|
|
|
@ -317,6 +317,7 @@ class TimesheetController extends BaseApiController
|
|||
$form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [
|
||||
'include_rate' => $this->isGranted('edit_rate', $timesheet),
|
||||
'include_exported' => $this->isGranted('edit_export', $timesheet),
|
||||
'include_billable' => $this->isGranted('edit_billable', $timesheet),
|
||||
'include_user' => $this->isGranted('create_other_timesheet'),
|
||||
'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(),
|
||||
'allow_end_datetime' => $mode->canUpdateTimesWithAPI(),
|
||||
|
@ -394,6 +395,7 @@ class TimesheetController extends BaseApiController
|
|||
$form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [
|
||||
'include_rate' => $this->isGranted('edit_rate', $timesheet),
|
||||
'include_exported' => $this->isGranted('edit_export', $timesheet),
|
||||
'include_billable' => $this->isGranted('edit_billable', $timesheet),
|
||||
'include_user' => $this->isGranted('edit', $timesheet),
|
||||
'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(),
|
||||
'allow_end_datetime' => $mode->canUpdateTimesWithAPI(),
|
||||
|
@ -614,12 +616,10 @@ class TimesheetController extends BaseApiController
|
|||
@trigger_error('Setting the "copy" attribute in "restart timesheet" API to something else then "all" is deprecated', E_USER_DEPRECATED);
|
||||
}
|
||||
|
||||
$copyTimesheet
|
||||
->setHourlyRate($timesheet->getHourlyRate())
|
||||
->setFixedRate($timesheet->getFixedRate())
|
||||
->setDescription($timesheet->getDescription())
|
||||
->setBillable($timesheet->isBillable())
|
||||
;
|
||||
$copyTimesheet->setHourlyRate($timesheet->getHourlyRate());
|
||||
$copyTimesheet->setFixedRate($timesheet->getFixedRate());
|
||||
$copyTimesheet->setDescription($timesheet->getDescription());
|
||||
$copyTimesheet->setBillable($timesheet->isBillable());
|
||||
|
||||
foreach ($timesheet->getTags() as $tag) {
|
||||
$copyTimesheet->addTag($tag);
|
||||
|
|
|
@ -232,11 +232,6 @@ class SystemConfiguration implements SystemBundleConfiguration
|
|||
return (string) $this->find('timesheet.default_begin');
|
||||
}
|
||||
|
||||
public function getTimesheetDefaultBillable(): bool
|
||||
{
|
||||
return (bool) $this->find('defaults.timesheet.billable');
|
||||
}
|
||||
|
||||
public function isTimesheetAllowFutureTimes(): bool
|
||||
{
|
||||
return (bool) $this->find('timesheet.rules.allow_future_times');
|
||||
|
|
|
@ -383,10 +383,6 @@ final class SystemConfigurationController extends AbstractController
|
|||
->setConstraints([
|
||||
new GreaterThanOrEqual(['value' => 0])
|
||||
]),
|
||||
(new Configuration())
|
||||
->setName('defaults.timesheet.billable')
|
||||
->setType(YesNoType::class)
|
||||
->setOptions(['help' => 'default_value_new', 'label' => 'label.billable']),
|
||||
]),
|
||||
(new SystemConfigurationModel('quick_entry'))
|
||||
->setTranslation('quick_entry.title')
|
||||
|
|
|
@ -455,6 +455,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
|||
'action' => $this->generateUrl($this->getMultiUpdateRoute(), []),
|
||||
'method' => 'POST',
|
||||
'include_exported' => $this->isGranted($this->getPermissionEditExport()),
|
||||
'include_billable' => $this->isGranted($this->getPermissionEditBillable()),
|
||||
'include_rate' => $this->isGranted($this->getPermissionEditRate()),
|
||||
'include_user' => $this->includeUserInForms('multi'),
|
||||
]);
|
||||
|
@ -482,6 +483,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
|||
'action' => $action,
|
||||
'include_rate' => $this->isGranted('edit_rate', $entry),
|
||||
'include_exported' => $this->isGranted('edit_export', $entry),
|
||||
'include_billable' => $this->isGranted('edit_billable', $entry),
|
||||
'include_user' => $this->includeUserInForms('create'),
|
||||
'allow_begin_datetime' => $mode->canEditBegin(),
|
||||
'allow_end_datetime' => $mode->canEditEnd(),
|
||||
|
@ -510,6 +512,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
|||
]),
|
||||
'include_rate' => $this->isGranted('edit_rate', $entry),
|
||||
'include_exported' => $this->isGranted('edit_export', $entry),
|
||||
'include_billable' => $this->isGranted('edit_billable', $entry),
|
||||
'include_user' => $this->includeUserInForms('edit'),
|
||||
'allow_begin_datetime' => $mode->canEditBegin(),
|
||||
'allow_end_datetime' => $mode->canEditEnd(),
|
||||
|
@ -549,6 +552,11 @@ abstract class TimesheetAbstractController extends AbstractController
|
|||
return 'edit_export_own_timesheet';
|
||||
}
|
||||
|
||||
protected function getPermissionEditBillable(): string
|
||||
{
|
||||
return 'edit_billable_own_timesheet';
|
||||
}
|
||||
|
||||
protected function getPermissionEditRate(): string
|
||||
{
|
||||
return 'edit_rate_own_timesheet';
|
||||
|
|
|
@ -153,8 +153,9 @@ class TimesheetTeamController extends TimesheetAbstractController
|
|||
|
||||
return $this->createForm(TimesheetMultiUserEditForm::class, $entry, [
|
||||
'action' => $this->generateUrl('admin_timesheet_create_multiuser'),
|
||||
'include_rate' => $this->isGranted('edit_rate', $entry),
|
||||
'include_exported' => $this->isGranted('edit_export', $entry),
|
||||
'include_rate' => $this->isGranted($this->getPermissionEditRate()),
|
||||
'include_exported' => $this->isGranted($this->getPermissionEditExport()),
|
||||
'include_billable' => $this->isGranted($this->getPermissionEditBillable()),
|
||||
'include_user' => $this->includeUserInForms('create'),
|
||||
'allow_begin_datetime' => $mode->canEditBegin(),
|
||||
'allow_end_datetime' => $mode->canEditEnd(),
|
||||
|
@ -205,6 +206,11 @@ class TimesheetTeamController extends TimesheetAbstractController
|
|||
return 'edit_export_other_timesheet';
|
||||
}
|
||||
|
||||
protected function getPermissionEditBillable(): string
|
||||
{
|
||||
return 'edit_billable_other_timesheet';
|
||||
}
|
||||
|
||||
protected function getPermissionEditRate(): string
|
||||
{
|
||||
return 'edit_rate_other_timesheet';
|
||||
|
|
|
@ -47,7 +47,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||
* }
|
||||
* )
|
||||
*
|
||||
* @Exporter\Order({"id", "name", "project", "budget", "timeBudget", "budgetType", "color", "visible", "comment"})
|
||||
* @Exporter\Order({"id", "name", "project", "budget", "timeBudget", "budgetType", "color", "visible", "comment", "billable"})
|
||||
* @Exporter\Expose("project", label="label.project", exp="object.getProject() === null ? null : object.getProject().getName()")
|
||||
*/
|
||||
class Activity implements EntityWithMetaFields, EntityWithBudget
|
||||
|
@ -123,6 +123,18 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
|
|||
* @Assert\NotNull()
|
||||
*/
|
||||
private $visible = true;
|
||||
/**
|
||||
* @var bool
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
*
|
||||
* @Exporter\Expose(label="label.billable", type="boolean")
|
||||
*
|
||||
* @ORM\Column(name="billable", type="boolean", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $billable = true;
|
||||
/**
|
||||
* Meta fields
|
||||
*
|
||||
|
@ -227,6 +239,16 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
|
|||
return $this->visible;
|
||||
}
|
||||
|
||||
public function setBillable(bool $billable): void
|
||||
{
|
||||
$this->billable = $billable;
|
||||
}
|
||||
|
||||
public function isBillable(): bool
|
||||
{
|
||||
return $this->billable;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection|MetaTableTypeInterface[]
|
||||
*/
|
||||
|
|
|
@ -27,7 +27,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||
*
|
||||
* @Serializer\ExclusionPolicy("all")
|
||||
*
|
||||
* @Exporter\Order({"id", "name", "company", "number", "vatId", "address", "contact","email", "phone", "mobile", "fax", "homepage", "country", "currency", "timezone", "budget", "timeBudget", "budgetType", "color", "visible", "teams", "comment"})
|
||||
* @Exporter\Order({"id", "name", "company", "number", "vatId", "address", "contact","email", "phone", "mobile", "fax", "homepage", "country", "currency", "timezone", "budget", "timeBudget", "budgetType", "color", "visible", "teams", "comment", "billable"})
|
||||
* @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array")
|
||||
*/
|
||||
class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
|
@ -98,6 +98,18 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
|||
* @Assert\NotNull()
|
||||
*/
|
||||
private $visible = true;
|
||||
/**
|
||||
* @var bool
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
*
|
||||
* @Exporter\Expose(label="label.billable", type="boolean")
|
||||
*
|
||||
* @ORM\Column(name="billable", type="boolean", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $billable = true;
|
||||
/**
|
||||
* @var string|null
|
||||
*
|
||||
|
@ -353,6 +365,16 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
|||
return $this->visible;
|
||||
}
|
||||
|
||||
public function setBillable(bool $billable): void
|
||||
{
|
||||
$this->billable = $billable;
|
||||
}
|
||||
|
||||
public function isBillable(): bool
|
||||
{
|
||||
return $this->billable;
|
||||
}
|
||||
|
||||
public function setCompany(?string $company): Customer
|
||||
{
|
||||
$this->company = $company;
|
||||
|
|
|
@ -48,7 +48,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||
* }
|
||||
* )
|
||||
*
|
||||
* @Exporter\Order({"id", "name", "customer", "orderNumber", "orderDate", "start", "end", "budget", "timeBudget", "budgetType", "color", "visible", "teams", "comment"})
|
||||
* @Exporter\Order({"id", "name", "customer", "orderNumber", "orderDate", "start", "end", "budget", "timeBudget", "budgetType", "color", "visible", "teams", "comment", "billable"})
|
||||
* @Exporter\Expose("customer", label="label.customer", exp="object.getCustomer() === null ? null : object.getCustomer().getName()")
|
||||
* @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array")
|
||||
*/
|
||||
|
@ -195,6 +195,18 @@ class Project implements EntityWithMetaFields, EntityWithBudget
|
|||
* @Assert\NotNull()
|
||||
*/
|
||||
private $visible = true;
|
||||
/**
|
||||
* @var bool
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
*
|
||||
* @Exporter\Expose(label="label.billable", type="boolean")
|
||||
*
|
||||
* @ORM\Column(name="billable", type="boolean", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $billable = true;
|
||||
/**
|
||||
* Meta fields
|
||||
*
|
||||
|
@ -294,6 +306,16 @@ class Project implements EntityWithMetaFields, EntityWithBudget
|
|||
return $this->visible;
|
||||
}
|
||||
|
||||
public function setBillable(bool $billable): void
|
||||
{
|
||||
$this->billable = $billable;
|
||||
}
|
||||
|
||||
public function isBillable(): bool
|
||||
{
|
||||
return $this->billable;
|
||||
}
|
||||
|
||||
public function getOrderNumber(): ?string
|
||||
{
|
||||
return $this->orderNumber;
|
||||
|
|
|
@ -98,6 +98,11 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
|
|||
*/
|
||||
public const OVERTIME = 'overtime';
|
||||
|
||||
public const BILLABLE_AUTOMATIC = 'auto';
|
||||
public const BILLABLE_YES = 'yes';
|
||||
public const BILLABLE_NO = 'no';
|
||||
public const BILLABLE_DEFAULT = 'default';
|
||||
|
||||
/**
|
||||
* @var int|null
|
||||
*
|
||||
|
@ -269,6 +274,11 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
|
|||
* @Assert\NotNull()
|
||||
*/
|
||||
private $billable = true;
|
||||
/**
|
||||
* Internal property used to determine whether the billable field should be calculated automatically.
|
||||
* @var string
|
||||
*/
|
||||
private $billableMode = self::BILLABLE_DEFAULT;
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
|
@ -649,6 +659,11 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
|
|||
return $this->billable;
|
||||
}
|
||||
|
||||
public function getBillable(): bool
|
||||
{
|
||||
return $this->billable;
|
||||
}
|
||||
|
||||
public function setBillable(bool $billable): Timesheet
|
||||
{
|
||||
$this->billable = $billable;
|
||||
|
@ -656,6 +671,16 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
|
|||
return $this;
|
||||
}
|
||||
|
||||
public function getBillableMode(): string
|
||||
{
|
||||
return $this->billableMode;
|
||||
}
|
||||
|
||||
public function setBillableMode(string $billableMode): void
|
||||
{
|
||||
$this->billableMode = $billableMode;
|
||||
}
|
||||
|
||||
public function getFixedRate(): ?float
|
||||
{
|
||||
return $this->fixedRate;
|
||||
|
|
|
@ -10,12 +10,22 @@
|
|||
namespace App\Form\API;
|
||||
|
||||
use App\Form\TimesheetEditForm;
|
||||
use App\Form\Type\BillableType;
|
||||
use App\Form\Type\TagsInputType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class TimesheetApiEditForm extends TimesheetEditForm
|
||||
{
|
||||
protected function addBillable(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
if (!$options['include_billable']) {
|
||||
return;
|
||||
}
|
||||
|
||||
$builder->add('billable', BillableType::class, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
|
@ -49,6 +59,7 @@ class TimesheetApiEditForm extends TimesheetEditForm
|
|||
// because the docs are cached without these fields otherwise
|
||||
'include_user' => true,
|
||||
'include_exported' => true,
|
||||
'include_billable' => true,
|
||||
'include_rate' => true,
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Type\BillableType;
|
||||
use App\Form\Type\BudgetType;
|
||||
use App\Form\Type\DurationType;
|
||||
use App\Form\Type\MetaFieldsCollectionType;
|
||||
|
@ -48,7 +49,9 @@ trait EntityFormTrait
|
|||
$builder
|
||||
->add('visible', YesNoType::class, [
|
||||
'label' => 'label.visible',
|
||||
]);
|
||||
])
|
||||
->add('billable', BillableType::class)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -265,7 +265,7 @@ class TimesheetMultiUpdate extends AbstractType
|
|||
'include_user' => false,
|
||||
'include_rate' => false,
|
||||
'include_exported' => false,
|
||||
'include_billable' => true,
|
||||
'include_billable' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
namespace App\Form;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
use App\Form\Type\BillableType;
|
||||
use App\Form\Type\DateTimePickerType;
|
||||
use App\Form\Type\DescriptionType;
|
||||
use App\Form\Type\DurationType;
|
||||
|
@ -18,10 +17,12 @@ use App\Form\Type\FixedRateType;
|
|||
use App\Form\Type\HourlyRateType;
|
||||
use App\Form\Type\MetaFieldsCollectionType;
|
||||
use App\Form\Type\TagsType;
|
||||
use App\Form\Type\TimesheetBillableType;
|
||||
use App\Form\Type\UserType;
|
||||
use App\Form\Type\YesNoType;
|
||||
use App\Repository\CustomerRepository;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\CallbackTransformer;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
|
@ -169,7 +170,7 @@ class TimesheetEditForm extends AbstractType
|
|||
{
|
||||
$durationOptions = [
|
||||
'required' => false,
|
||||
'docu_chapter' => 'timesheet.html#duration-format',
|
||||
'docu_chapter' => 'duration-format.html',
|
||||
'attr' => [
|
||||
'placeholder' => '0:00',
|
||||
],
|
||||
|
@ -261,11 +262,57 @@ class TimesheetEditForm extends AbstractType
|
|||
|
||||
protected function addBillable(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
if (!$options['include_billable']) {
|
||||
return;
|
||||
if ($options['include_billable']) {
|
||||
$builder->add('billableMode', TimesheetBillableType::class, []);
|
||||
}
|
||||
|
||||
$builder->add('billable', BillableType::class, []);
|
||||
$builder->addModelTransformer(new CallbackTransformer(
|
||||
function (Timesheet $record) {
|
||||
if ($record->getBillableMode() === Timesheet::BILLABLE_DEFAULT) {
|
||||
if ($record->isBillable()) {
|
||||
$record->setBillableMode(Timesheet::BILLABLE_YES);
|
||||
} else {
|
||||
$record->setBillableMode(Timesheet::BILLABLE_NO);
|
||||
}
|
||||
}
|
||||
|
||||
return $record;
|
||||
},
|
||||
function (Timesheet $record) {
|
||||
switch ($record->getBillableMode()) {
|
||||
case Timesheet::BILLABLE_NO:
|
||||
$record->setBillable(false);
|
||||
break;
|
||||
case Timesheet::BILLABLE_YES:
|
||||
$record->setBillable(true);
|
||||
break;
|
||||
case Timesheet::BILLABLE_AUTOMATIC:
|
||||
$billable = true;
|
||||
|
||||
$activity = $record->getActivity();
|
||||
if ($activity !== null && !$activity->isBillable()) {
|
||||
$billable = false;
|
||||
}
|
||||
|
||||
$project = $record->getProject();
|
||||
if ($billable && $project !== null && !$project->isBillable()) {
|
||||
$billable = false;
|
||||
}
|
||||
|
||||
if ($billable && $project !== null) {
|
||||
$customer = $project->getCustomer();
|
||||
if ($customer !== null && !$customer->isBillable()) {
|
||||
$billable = false;
|
||||
}
|
||||
}
|
||||
|
||||
$record->setBillable($billable);
|
||||
break;
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -14,7 +14,6 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
|||
|
||||
/**
|
||||
* Custom form field type to select if something is billable.
|
||||
* To be used in combination with the invoicing system.
|
||||
*/
|
||||
class BillableType extends AbstractType
|
||||
{
|
||||
|
|
44
src/Form/Type/TimesheetBillableType.php
Normal file
44
src/Form/Type/TimesheetBillableType.php
Normal file
|
@ -0,0 +1,44 @@
|
|||
<?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\Type;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Custom form field type to select if a timesheet is billable.
|
||||
*/
|
||||
class TimesheetBillableType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'label' => 'label.billable',
|
||||
'choices' => [
|
||||
'automatic' => Timesheet::BILLABLE_AUTOMATIC,
|
||||
'yes' => Timesheet::BILLABLE_YES,
|
||||
'no' => Timesheet::BILLABLE_NO,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getParent()
|
||||
{
|
||||
return ChoiceType::class;
|
||||
}
|
||||
}
|
49
src/Migrations/Version20220315224645.php
Normal file
49
src/Migrations/Version20220315224645.php
Normal file
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Doctrine\AbstractMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
final class Version20220315224645 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds billable fields to Customer, Project and Activity';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$customers = $schema->getTable('kimai2_customers');
|
||||
$customers->addColumn('billable', 'boolean', ['notnull' => true, 'default' => true]);
|
||||
|
||||
$projects = $schema->getTable('kimai2_projects');
|
||||
$projects->addColumn('billable', 'boolean', ['notnull' => true, 'default' => true]);
|
||||
|
||||
$activities = $schema->getTable('kimai2_activities');
|
||||
$activities->addColumn('billable', 'boolean', ['notnull' => true, 'default' => true]);
|
||||
|
||||
$this->addSql('DELETE from kimai2_configuration WHERE `name` = "defaults.timesheet.billable"');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$customers = $schema->getTable('kimai2_customers');
|
||||
$customers->dropColumn('billable');
|
||||
|
||||
$projects = $schema->getTable('kimai2_projects');
|
||||
$projects->dropColumn('billable');
|
||||
|
||||
$activities = $schema->getTable('kimai2_activities');
|
||||
$activities->dropColumn('billable');
|
||||
}
|
||||
}
|
|
@ -110,7 +110,7 @@ final class TimesheetService
|
|||
$mode = $this->trackingModeService->getActiveMode();
|
||||
$mode->create($timesheet, $request);
|
||||
|
||||
$timesheet->setBillable($this->configuration->getTimesheetDefaultBillable());
|
||||
$timesheet->setBillableMode(Timesheet::BILLABLE_AUTOMATIC);
|
||||
|
||||
return $timesheet;
|
||||
}
|
||||
|
@ -118,6 +118,7 @@ final class TimesheetService
|
|||
/**
|
||||
* @param Timesheet $timesheet
|
||||
* @param Timesheet $copyFrom
|
||||
* @return Timesheet
|
||||
* @throws ValidationFailedException for invalid timesheets or running timesheets that should be stopped
|
||||
* @throws InvalidArgumentException for already persisted timesheets
|
||||
* @throws AccessDeniedException if user is not allowed to start timesheet
|
||||
|
|
|
@ -44,6 +44,7 @@ final class TimesheetVoter extends Voter
|
|||
self::VIEW_RATE,
|
||||
self::EDIT_RATE,
|
||||
self::EDIT_EXPORT,
|
||||
'edit_billable',
|
||||
'duplicate'
|
||||
];
|
||||
|
||||
|
@ -130,6 +131,7 @@ final class TimesheetVoter extends Voter
|
|||
case self::VIEW:
|
||||
case self::EXPORT:
|
||||
case self::EDIT_EXPORT:
|
||||
case 'edit_billable':
|
||||
$permission .= $attribute;
|
||||
break;
|
||||
|
||||
|
|
|
@ -41,6 +41,14 @@
|
|||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if not activity.billable %}
|
||||
<tr>
|
||||
<th>{{ 'label.billable'|trans }}</th>
|
||||
<td colspan="3">
|
||||
{{ widgets.label_boolean(activity.billable) }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if not activity.global %}
|
||||
<tr {{ widgets.customer_row_attr(activity.project.customer, now) }}>
|
||||
<th>{{ 'label.customer'|trans }}</th>
|
||||
|
|
|
@ -41,7 +41,14 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ form_row(form.visible) }}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.visible) }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.billable) }}
|
||||
</div>
|
||||
</div>
|
||||
{% if form.metaFields is defined and form.metaFields is not empty %}
|
||||
{% for meta in form.metaFields|sort((a, b) => a.vars.data.order <=> b.vars.data.order) %}
|
||||
{{ form_row(meta) }}
|
||||
|
|
|
@ -34,6 +34,14 @@
|
|||
<td>{{ widgets.label_boolean(customer.visible) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if not customer.billable %}
|
||||
<tr>
|
||||
<th>{{ 'label.billable'|trans }}</th>
|
||||
<td colspan="3">
|
||||
{{ widgets.label_boolean(customer.billable) }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if customer.address is not empty %}
|
||||
<tr>
|
||||
<th>{{ 'label.address'|trans }}</th>
|
||||
|
|
|
@ -80,7 +80,14 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ form_row(form.visible) }}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.visible) }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.billable) }}
|
||||
</div>
|
||||
</div>
|
||||
{% if form.metaFields is defined and form.metaFields is not empty %}
|
||||
{% for meta in form.metaFields|sort((a, b) => a.vars.data.order <=> b.vars.data.order) %}
|
||||
{{ form_row(meta) }}
|
||||
|
|
|
@ -49,6 +49,14 @@
|
|||
</td>
|
||||
</tr>
|
||||
{% if is_granted('details', project) %}
|
||||
{% if not project.billable %}
|
||||
<tr>
|
||||
<th>{{ 'label.billable'|trans }}</th>
|
||||
<td colspan="3">
|
||||
{{ widgets.label_boolean(project.billable) }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if project.orderNumber is not empty %}
|
||||
<tr>
|
||||
<th>{{ 'label.orderNumber'|trans }}</th>
|
||||
|
|
|
@ -52,7 +52,14 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ form_row(form.visible) }}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.visible) }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.billable) }}
|
||||
</div>
|
||||
</div>
|
||||
{% if form.metaFields is defined and form.metaFields is not empty %}
|
||||
{% for meta in form.metaFields|sort((a, b) => a.vars.data.order <=> b.vars.data.order) %}
|
||||
{{ form_row(meta) }}
|
||||
|
|
|
@ -78,14 +78,39 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.fixedRate is defined and form.hourlyRate is defined %}
|
||||
{% set colWidth = 0 %}
|
||||
{% if form.fixedRate is defined %}
|
||||
{% set colWidth = colWidth + 1 %}
|
||||
{% endif %}
|
||||
{% if form.hourlyRate is defined %}
|
||||
{% set colWidth = colWidth + 1 %}
|
||||
{% endif %}
|
||||
{% if form.billable is defined %}
|
||||
{% set colWidth = colWidth + 1 %}
|
||||
{% elseif form.billableMode is defined %}
|
||||
{% set colWidth = colWidth + 1 %}
|
||||
{% endif %}
|
||||
{% if colWidth > 0 %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.fixedRate, {'row_attr': {'class': 'timesheet_edit_form_row_fixedRate'}}) }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.hourlyRate, {'row_attr': {'class': 'timesheet_edit_form_row_hourlyRate'}}) }}
|
||||
</div>
|
||||
{% if form.fixedRate is defined %}
|
||||
<div class="col-md-{{ 12 / colWidth }}">
|
||||
{{ form_row(form.fixedRate, {'row_attr': {'class': 'timesheet_edit_form_row_fixedRate'}}) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.hourlyRate is defined %}
|
||||
<div class="col-md-{{ 12 / colWidth }}">
|
||||
{{ form_row(form.hourlyRate, {'row_attr': {'class': 'timesheet_edit_form_row_hourlyRate'}}) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.billable is defined %}
|
||||
<div class="col-md-{{ 12 / colWidth }}">
|
||||
{{ form_row(form.billable, {'row_attr': {'class': 'timesheet_edit_form_row_billable'}}) }}
|
||||
</div>
|
||||
{% elseif form.billableMode is defined %}
|
||||
<div class="col-md-{{ 12 / colWidth }}">
|
||||
{{ form_row(form.billableMode, {'row_attr': {'class': 'timesheet_edit_form_row_billable'}}) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.metaFields is defined and form.metaFields is not empty %}
|
||||
|
@ -93,19 +118,11 @@
|
|||
{{ form_row(meta) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if form.exported is defined or form.billable is defined %}
|
||||
{% set ebLength = (form.exported is defined and form.billable is defined) ? 6 : 12 %}
|
||||
{% if form.exported is defined %}
|
||||
<div class="row">
|
||||
{% if form.exported is defined %}
|
||||
<div class="col-md-{{ ebLength }}">
|
||||
<div class="col-md-12">
|
||||
{{ form_row(form.exported, {'row_attr': {'class': 'timesheet_edit_form_row_exported'}}) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.billable is defined %}
|
||||
<div class="col-md-{{ ebLength }}">
|
||||
{{ form_row(form.billable, {'row_attr': {'class': 'timesheet_edit_form_row_billable'}}) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ form_widget(form) }}
|
||||
|
|
|
@ -374,6 +374,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
|||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'visible' => 'bool',
|
||||
'billable' => 'bool',
|
||||
'color' => '@string',
|
||||
'number' => '@string',
|
||||
'comment' => '@string',
|
||||
|
@ -385,6 +386,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
|||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'visible' => 'boolean',
|
||||
'billable' => 'bool',
|
||||
'color' => '@string',
|
||||
'number' => '@string',
|
||||
'comment' => '@string',
|
||||
|
@ -399,6 +401,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
|||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'visible' => 'bool',
|
||||
'billable' => 'bool',
|
||||
'color' => '@string',
|
||||
'metaFields' => ['result' => 'array', 'type' => 'CustomerMeta'],
|
||||
'teams' => ['result' => 'array', 'type' => 'Team'],
|
||||
|
@ -427,6 +430,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
|||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'visible' => 'bool',
|
||||
'billable' => 'bool',
|
||||
'color' => '@string',
|
||||
'customer' => 'int',
|
||||
'comment' => '@string',
|
||||
|
@ -438,6 +442,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
|||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'visible' => 'bool',
|
||||
'billable' => 'bool',
|
||||
'color' => '@string',
|
||||
'customer' => ['result' => 'object', 'type' => 'Customer'],
|
||||
'comment' => '@string',
|
||||
|
@ -449,6 +454,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
|||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'visible' => 'bool',
|
||||
'billable' => 'bool',
|
||||
'customer' => 'int',
|
||||
'color' => '@string',
|
||||
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
|
||||
|
@ -465,6 +471,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
|||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'visible' => 'bool',
|
||||
'billable' => 'bool',
|
||||
'customer' => 'int',
|
||||
'color' => '@string',
|
||||
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
|
||||
|
@ -486,6 +493,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
|||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'visible' => 'bool',
|
||||
'billable' => 'bool',
|
||||
'project' => '@int',
|
||||
'color' => '@string',
|
||||
'comment' => '@string',
|
||||
|
@ -496,6 +504,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
|||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'visible' => 'bool',
|
||||
'billable' => 'bool',
|
||||
'project' => ['result' => 'object', 'type' => '@ProjectExpanded'],
|
||||
'color' => '@string',
|
||||
'comment' => '@string',
|
||||
|
@ -507,6 +516,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
|||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'visible' => 'bool',
|
||||
'billable' => 'bool',
|
||||
'project' => '@int',
|
||||
'color' => '@string',
|
||||
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
|
||||
|
@ -521,6 +531,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
|||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'visible' => 'bool',
|
||||
'billable' => 'bool',
|
||||
'project' => '@int',
|
||||
'color' => '@string',
|
||||
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
|
||||
|
|
|
@ -35,7 +35,7 @@ class PermissionControllerTest extends ControllerBaseTest
|
|||
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
||||
$this->assertAccessIsGranted($client, '/admin/permissions');
|
||||
$this->assertHasDataTable($client);
|
||||
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 121);
|
||||
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 123);
|
||||
$this->assertPageActions($client, [
|
||||
//'back' => $this->createUrl('/admin/user/'),
|
||||
'create modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),
|
||||
|
|
|
@ -32,6 +32,7 @@ class ActivityTest extends AbstractEntityTest
|
|||
$this->assertNull($sut->getName());
|
||||
$this->assertNull($sut->getComment());
|
||||
$this->assertTrue($sut->isVisible());
|
||||
$this->assertTrue($sut->isBillable());
|
||||
$this->assertTrue($sut->isGlobal());
|
||||
$this->assertNull($sut->getColor());
|
||||
self::assertFalse($sut->hasColor());
|
||||
|
@ -57,6 +58,11 @@ class ActivityTest extends AbstractEntityTest
|
|||
$this->assertInstanceOf(Activity::class, $sut->setVisible(false));
|
||||
$this->assertFalse($sut->isVisible());
|
||||
|
||||
$sut->setVisible(false);
|
||||
self::assertFalse($sut->isVisible());
|
||||
$sut->setVisible(true);
|
||||
self::assertTrue($sut->isVisible());
|
||||
|
||||
$this->assertInstanceOf(Activity::class, $sut->setComment('hello world'));
|
||||
$this->assertEquals('hello world', $sut->getComment());
|
||||
|
||||
|
@ -145,6 +151,7 @@ class ActivityTest extends AbstractEntityTest
|
|||
['label.color', 'string'],
|
||||
['label.visible', 'boolean'],
|
||||
['label.comment', 'string'],
|
||||
['label.billable', 'boolean'],
|
||||
];
|
||||
|
||||
self::assertCount(\count($expected), $columns);
|
||||
|
|
|
@ -31,6 +31,7 @@ class CustomerTest extends AbstractEntityTest
|
|||
self::assertNull($sut->getNumber());
|
||||
self::assertNull($sut->getComment());
|
||||
self::assertTrue($sut->isVisible());
|
||||
self::assertTrue($sut->isBillable());
|
||||
|
||||
self::assertNull($sut->getCompany());
|
||||
self::assertNull($sut->getVatId());
|
||||
|
@ -71,6 +72,11 @@ class CustomerTest extends AbstractEntityTest
|
|||
self::assertInstanceOf(Customer::class, $sut->setVisible(false));
|
||||
self::assertFalse($sut->isVisible());
|
||||
|
||||
$sut->setVisible(false);
|
||||
self::assertFalse($sut->isVisible());
|
||||
$sut->setVisible(true);
|
||||
self::assertTrue($sut->isVisible());
|
||||
|
||||
self::assertInstanceOf(Customer::class, $sut->setComment('hello world'));
|
||||
self::assertEquals('hello world', $sut->getComment());
|
||||
|
||||
|
@ -198,6 +204,7 @@ class CustomerTest extends AbstractEntityTest
|
|||
['label.color', 'string'],
|
||||
['label.visible', 'boolean'],
|
||||
['label.comment', 'string'],
|
||||
['label.billable', 'boolean'],
|
||||
];
|
||||
|
||||
self::assertCount(\count($expected), $columns);
|
||||
|
|
|
@ -36,6 +36,7 @@ class ProjectTest extends AbstractEntityTest
|
|||
self::assertNull($sut->getEnd());
|
||||
self::assertNull($sut->getComment());
|
||||
self::assertTrue($sut->isVisible());
|
||||
self::assertTrue($sut->isBillable());
|
||||
self::assertNull($sut->getColor());
|
||||
self::assertFalse($sut->hasColor());
|
||||
self::assertInstanceOf(Collection::class, $sut->getMetaFields());
|
||||
|
@ -96,6 +97,11 @@ class ProjectTest extends AbstractEntityTest
|
|||
|
||||
self::assertInstanceOf(Project::class, $sut->setVisible(false));
|
||||
self::assertFalse($sut->isVisible());
|
||||
|
||||
$sut->setVisible(false);
|
||||
self::assertFalse($sut->isVisible());
|
||||
$sut->setVisible(true);
|
||||
self::assertTrue($sut->isVisible());
|
||||
}
|
||||
|
||||
public function testMetaFields()
|
||||
|
@ -173,6 +179,7 @@ class ProjectTest extends AbstractEntityTest
|
|||
['label.color', 'string'],
|
||||
['label.visible', 'boolean'],
|
||||
['label.comment', 'string'],
|
||||
['label.billable', 'boolean'],
|
||||
];
|
||||
|
||||
self::assertCount(\count($expected), $columns);
|
||||
|
|
|
@ -72,6 +72,7 @@ class EntityWithMetaFieldsExporterTest extends TestCase
|
|||
self::assertEquals('#ababab', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
|
||||
self::assertFalse($worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
|
||||
self::assertEquals('Lorem Ipsum', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
|
||||
self::assertTrue($worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
|
||||
self::assertEquals('some magic', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
|
||||
self::assertEquals('is happening', $worksheet->getCellByColumnAndRow(++$i, 2)->getValue());
|
||||
}
|
||||
|
|
|
@ -21,6 +21,10 @@
|
|||
<source>both</source>
|
||||
<target>Beides</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="U_D.NIy" resname="automatic">
|
||||
<source>automatic</source>
|
||||
<target>Automatisch</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="KLIuHvt" resname="This is a mandatory field">
|
||||
<source>This is a mandatory field</source>
|
||||
<target>Pflichtfeld</target>
|
||||
|
|
|
@ -21,6 +21,10 @@
|
|||
<source>both</source>
|
||||
<target>Both</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="U_D.NIy" resname="automatic">
|
||||
<source>automatic</source>
|
||||
<target>Automatic</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="KLIuHvt" resname="This is a mandatory field">
|
||||
<source>This is a mandatory field</source>
|
||||
<target>Mandatory</target>
|
||||
|
|
Loading…
Reference in a new issue