weekly quick-entry form (#2793)
This commit is contained in:
@@ -6,6 +6,14 @@ coverage:
|
||||
precision: 2
|
||||
round: down
|
||||
range: "80...100"
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
threshold: 0.5%
|
||||
patch:
|
||||
default:
|
||||
threshold: 50%
|
||||
changes: no
|
||||
|
||||
parsers:
|
||||
gcov:
|
||||
|
||||
@@ -38,6 +38,10 @@ export default class KimaiDateUtils extends KimaiPlugin {
|
||||
return this.formatMomentDuration(duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {moment.Duration} duration
|
||||
* @returns {string|*}
|
||||
*/
|
||||
formatMomentDuration(duration) {
|
||||
const hours = parseInt(duration.asHours()).toString();
|
||||
const minutes = duration.minutes();
|
||||
@@ -58,7 +62,41 @@ export default class KimaiDateUtils extends KimaiPlugin {
|
||||
|
||||
const format = this.getConfiguration('formatDuration');
|
||||
|
||||
return format.replace('%h', hours).replace('%m', ('0'+minutes).substr(-2)).replace('%s', ('0'+seconds).substr(-2));
|
||||
return format.replace('%h', hours).replace('%m', ('0' + minutes).substr(-2)).replace('%s', ('0' + seconds).substr(-2));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} duration
|
||||
* @returns {int}
|
||||
*/
|
||||
getSecondsFromDurationString(duration)
|
||||
{
|
||||
duration = duration.trim().toUpperCase();
|
||||
let momentDuration = moment.duration(NaN);
|
||||
|
||||
if (duration.indexOf(':') !== -1) {
|
||||
momentDuration = moment.duration(duration);
|
||||
} else if (duration.indexOf('.') !== -1 || duration.indexOf(',') !== -1) {
|
||||
duration = duration.replace(/,/, '.');
|
||||
duration = (parseFloat(duration) * 3600).toString();
|
||||
momentDuration = moment.duration('PT' + duration + 'S');
|
||||
} else if (duration.indexOf('H') !== -1 || duration.indexOf('M') !== -1 || duration.indexOf('S') !== -1) {
|
||||
/* D for days does not work, because 'PT1H' but with days 'P1D' is used */
|
||||
momentDuration = moment.duration('PT' + duration);
|
||||
} else {
|
||||
let c = parseInt(duration);
|
||||
let d = parseInt(duration).toFixed();
|
||||
if (!isNaN(c) && duration === d) {
|
||||
duration = (c * 3600).toString();
|
||||
momentDuration = moment.duration('PT' + duration + 'S');
|
||||
}
|
||||
}
|
||||
|
||||
if (!momentDuration.isValid()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return momentDuration.asSeconds();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -23,19 +23,21 @@ export default class KimaiFormSelect extends KimaiPlugin {
|
||||
return 'form-select';
|
||||
}
|
||||
|
||||
activateSelectPicker(selector, container) {
|
||||
const elementSelector = this.selector;
|
||||
let options = {};
|
||||
if (container !== undefined) {
|
||||
options = {
|
||||
dropdownParent: $(container),
|
||||
};
|
||||
}
|
||||
init() {
|
||||
// selects the original value inside select2 dropdowns, as the "reset" event (the updated option)
|
||||
// is not automatically catched by select2
|
||||
jQuery('body').on('reset', 'form', function(event) {
|
||||
setTimeout(function() {
|
||||
jQuery(event.target).find(this.selector).trigger('change');
|
||||
}, 10);
|
||||
});
|
||||
|
||||
const self = this;
|
||||
|
||||
// Function to match the name of the parent and not only the names of the children
|
||||
// Based on the original matcher function of Select2: https://github.com/select2/select2/blob/5765090318c4d382ae56463cfa25ba8ca7bdd495/src/js/select2/defaults.js#L272
|
||||
// More information: https://select2.org/searching | https://github.com/select2/docs/blob/develop/pages/11.searching/docs.md
|
||||
function matcher(params, data) {
|
||||
this.matcher = function (params, data) {
|
||||
// Always return the object if there is nothing to compare
|
||||
if (jQuery.trim(params.term) === '') {
|
||||
return data;
|
||||
@@ -86,7 +88,7 @@ export default class KimaiFormSelect extends KimaiPlugin {
|
||||
for (let c = data.children.length - 1; c >= 0; c--) {
|
||||
let child = data.children[c];
|
||||
|
||||
let matches = matcher(newParams, child);
|
||||
let matches = self.matcher(newParams, child);
|
||||
|
||||
// If there wasn't a match, remove the object in the array
|
||||
if (matches === null) {
|
||||
@@ -103,29 +105,42 @@ export default class KimaiFormSelect extends KimaiPlugin {
|
||||
// If the option or its children do not contain the term, don't return anything
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
activateSelectPickerByElement(node, container) {
|
||||
let options = {};
|
||||
if (container !== undefined) {
|
||||
options = {
|
||||
dropdownParent: jQuery(container),
|
||||
};
|
||||
}
|
||||
|
||||
options = {...options, ...{
|
||||
language: this.getConfiguration('locale').replace('_', '-'),
|
||||
theme: "bootstrap",
|
||||
matcher: matcher
|
||||
matcher: this.matcher
|
||||
}};
|
||||
|
||||
if (node.dataset['renderer'] !== undefined && node.dataset['renderer'] === 'color') {
|
||||
const templateResultFunc = function (state) {
|
||||
return jQuery('<span><span style="background-color:'+state.id+'; width: 20px; height: 20px; display: inline-block; margin-right: 10px;"> </span>' + state.text + '</span>');
|
||||
};
|
||||
|
||||
let optionsColor = {...options, ...{
|
||||
const colorOptions = {...options, ...{
|
||||
templateSelection: templateResultFunc,
|
||||
templateResult: templateResultFunc
|
||||
}};
|
||||
|
||||
jQuery(selector + ' ' + elementSelector + ':not([data-renderer=color])').select2(options);
|
||||
jQuery(selector + ' ' + elementSelector + '[data-renderer=color]').select2(optionsColor);
|
||||
jQuery(node).select2(colorOptions);
|
||||
} else {
|
||||
jQuery(node).select2(options);
|
||||
}
|
||||
}
|
||||
|
||||
jQuery('body').on('reset', 'form', function(event){
|
||||
setTimeout(function() {
|
||||
jQuery(event.target).find(elementSelector).trigger('change');
|
||||
}, 10);
|
||||
activateSelectPicker(selector, container) {
|
||||
const self = this;
|
||||
jQuery(selector + ' ' + this.selector).each(function(i, el) {
|
||||
self.activateSelectPickerByElement(el, container);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -40,10 +40,10 @@ export default class KimaiSelectDataAPI extends KimaiPlugin {
|
||||
return;
|
||||
}
|
||||
|
||||
let formPrefix = jQuery(this).parents('form').first().attr('name');
|
||||
let formPrefix = this.dataset['formPrefix'];
|
||||
if (formPrefix === undefined || formPrefix === null) {
|
||||
formPrefix = '';
|
||||
} else {
|
||||
} else if (formPrefix.length > 0) {
|
||||
formPrefix += '_';
|
||||
}
|
||||
|
||||
@@ -74,11 +74,11 @@ export default class KimaiSelectDataAPI extends KimaiPlugin {
|
||||
let newApiUrl = apiUrl;
|
||||
|
||||
apiUrl.split('?')[1].split('&').forEach(item => {
|
||||
let [key, value] = item.split('=');
|
||||
let decoded = decodeURIComponent(value);
|
||||
let test = decoded.match(/%(.*)%/);
|
||||
const [key, value] = item.split('=');
|
||||
const decoded = decodeURIComponent(value);
|
||||
const test = decoded.match(/%(.*)%/);
|
||||
if (test !== null) {
|
||||
let targetField = jQuery('#' + formPrefix + test[1]);
|
||||
const targetField = jQuery('#' + formPrefix + test[1]);
|
||||
let newValue = '';
|
||||
if (targetField.length === 0) {
|
||||
// happens for example:
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
@import "variables";
|
||||
|
||||
table.dataTable.table > tbody > tr > td {
|
||||
vertical-align: middle;
|
||||
@@ -98,6 +99,28 @@ table.dataTable {
|
||||
}
|
||||
}
|
||||
}
|
||||
th.weekend,
|
||||
td.weekend {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
/* order is important, "today” should overwrite "weekend" therefor later in the file */
|
||||
th.today,
|
||||
td.today {
|
||||
background-color: $highlight-today;
|
||||
}
|
||||
th.total,
|
||||
td.total {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
/* Quick entry form */
|
||||
.form-dataTable {
|
||||
table.dataTable {
|
||||
.form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table.table-hover {
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
.dataTable {
|
||||
.weekend {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
.form-reporting .box-header .form-group {
|
||||
margin-right: 10px;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
/* For highlighting rows, fieldsets and so on */
|
||||
$highlight-bg: #f5f5f5;
|
||||
$highlight-today: #fcf8e3;
|
||||
|
||||
/**
|
||||
* BELOW: A simple one to one clone of /node_modules/admin-lte/build/less/variables.less
|
||||
|
||||
14
composer.lock
generated
14
composer.lock
generated
@@ -6149,16 +6149,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/flex",
|
||||
"version": "v1.16.3",
|
||||
"version": "v1.17.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/flex.git",
|
||||
"reference": "f05406b33681409b83285f4d2ff4efbca7c55b03"
|
||||
"reference": "782ef2269622b8349c4bc3dc795fc79d39e8a5b2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/flex/zipball/f05406b33681409b83285f4d2ff4efbca7c55b03",
|
||||
"reference": "f05406b33681409b83285f4d2ff4efbca7c55b03",
|
||||
"url": "https://api.github.com/repos/symfony/flex/zipball/782ef2269622b8349c4bc3dc795fc79d39e8a5b2",
|
||||
"reference": "782ef2269622b8349c4bc3dc795fc79d39e8a5b2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -6175,7 +6175,7 @@
|
||||
"type": "composer-plugin",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "1.16-dev"
|
||||
"dev-main": "1.17-dev"
|
||||
},
|
||||
"class": "Symfony\\Flex\\Flex"
|
||||
},
|
||||
@@ -6197,7 +6197,7 @@
|
||||
"description": "Composer plugin for Symfony",
|
||||
"support": {
|
||||
"issues": "https://github.com/symfony/flex/issues",
|
||||
"source": "https://github.com/symfony/flex/tree/v1.16.3"
|
||||
"source": "https://github.com/symfony/flex/tree/v1.17.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -6213,7 +6213,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2021-09-28T17:00:52+00:00"
|
||||
"time": "2021-10-14T06:14:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/form",
|
||||
|
||||
@@ -65,6 +65,9 @@ services:
|
||||
App\Validator\Constraints\TimesheetValidator:
|
||||
arguments: [!tagged timesheet.validator]
|
||||
|
||||
App\Validator\Constraints\QuickEntryTimesheetValidator:
|
||||
arguments: [!tagged timesheet.validator]
|
||||
|
||||
App\Widget\WidgetService:
|
||||
arguments:
|
||||
$renderer: !tagged widget.renderer
|
||||
|
||||
4
public/avatars/README.md
Normal file
4
public/avatars/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
This directory was used before version 1.15 to write generated user avatar images.
|
||||
This solution was replaced by CSS generated avatars and therefore this directory is not needed any longer.
|
||||
We'll keep it in GIT for all the custom cache clean scripts that might exist out there.
|
||||
This directory is @deprecated since 1.15 and will be removed with 2.0.
|
||||
File diff suppressed because one or more lines are too long
2
public/build/app.da44b7f8.js
Normal file
2
public/build/app.da44b7f8.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3,10 +3,10 @@
|
||||
"app": {
|
||||
"js": [
|
||||
"build/runtime.b8e7bb04.js",
|
||||
"build/app.e22732af.js"
|
||||
"build/app.da44b7f8.js"
|
||||
],
|
||||
"css": [
|
||||
"build/app.c57cdec8.css"
|
||||
"build/app.d2b280dd.css"
|
||||
]
|
||||
},
|
||||
"invoice": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"build/app.css": "build/app.c57cdec8.css",
|
||||
"build/app.js": "build/app.e22732af.js",
|
||||
"build/app.css": "build/app.d2b280dd.css",
|
||||
"build/app.js": "build/app.da44b7f8.js",
|
||||
"build/invoice.css": "build/invoice.ff32661a.css",
|
||||
"build/invoice.js": "build/invoice.19f36eca.js",
|
||||
"build/invoice-pdf.css": "build/invoice-pdf.9a7468ef.css",
|
||||
|
||||
@@ -115,7 +115,8 @@ final class ReloadCommand extends Command
|
||||
$io->warning(
|
||||
[
|
||||
'Cache could not be rebuilt.',
|
||||
'Please run the cache commands manually:',
|
||||
'Please run these commands to rebuild the cache manually:',
|
||||
'rm -r var/cache/*' . PHP_EOL .
|
||||
'bin/console cache:clear --env=' . $environment . PHP_EOL .
|
||||
'bin/console cache:warmup --env=' . $environment
|
||||
]
|
||||
|
||||
@@ -29,7 +29,6 @@ use App\Form\Toolbar\ActivityToolbarForm;
|
||||
use App\Form\Type\ActivityType;
|
||||
use App\Repository\ActivityRateRepository;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\Query\ActivityFormTypeQuery;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\TeamRepository;
|
||||
use Exception;
|
||||
@@ -314,6 +313,13 @@ final class ActivityController extends AbstractController
|
||||
{
|
||||
$stats = $statisticService->getActivityStatistics($activity);
|
||||
|
||||
$options = [
|
||||
'projects' => $activity->getProject(),
|
||||
'query_builder_for_user' => true,
|
||||
'ignore_activity' => $activity,
|
||||
'required' => false,
|
||||
];
|
||||
|
||||
$deleteForm = $this->createFormBuilder(null, [
|
||||
'attr' => [
|
||||
'data-form-event' => 'kimai.activityDelete',
|
||||
@@ -321,17 +327,7 @@ final class ActivityController extends AbstractController
|
||||
'data-msg-error' => 'action.delete.error',
|
||||
]
|
||||
])
|
||||
->add('activity', ActivityType::class, [
|
||||
'label' => 'label.activity',
|
||||
'query_builder' => function (ActivityRepository $repo) use ($activity) {
|
||||
$query = new ActivityFormTypeQuery();
|
||||
$query->addProject($activity->getProject());
|
||||
$query->setActivityToIgnore($activity);
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
'required' => false,
|
||||
])
|
||||
->add('activity', ActivityType::class, $options)
|
||||
->setAction($this->generateUrl('admin_activity_delete', ['id' => $activity->getId()]))
|
||||
->setMethod('POST')
|
||||
->getForm();
|
||||
|
||||
@@ -30,7 +30,6 @@ use App\Form\Type\CustomerType;
|
||||
use App\Repository\CustomerRateRepository;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\CustomerFormTypeQuery;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\TeamRepository;
|
||||
@@ -384,14 +383,8 @@ final class CustomerController extends AbstractController
|
||||
]
|
||||
])
|
||||
->add('customer', CustomerType::class, [
|
||||
'label' => 'label.customer',
|
||||
'query_builder' => function (CustomerRepository $repo) use ($customer) {
|
||||
$query = new CustomerFormTypeQuery();
|
||||
$query->setCustomerToIgnore($customer);
|
||||
$query->setUser($this->getUser());
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
'query_builder_for_user' => true,
|
||||
'ignore_customer' => $customer,
|
||||
'required' => false,
|
||||
])
|
||||
->setAction($this->generateUrl('admin_customer_delete', ['id' => $customer->getId()]))
|
||||
|
||||
@@ -35,7 +35,6 @@ use App\Repository\ActivityRepository;
|
||||
use App\Repository\ProjectRateRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\ProjectFormTypeQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\TeamRepository;
|
||||
use Pagerfanta\Pagerfanta;
|
||||
@@ -428,15 +427,9 @@ final class ProjectController extends AbstractController
|
||||
]
|
||||
])
|
||||
->add('project', ProjectType::class, [
|
||||
'label' => 'label.project',
|
||||
'query_builder' => function (ProjectRepository $repo) use ($project) {
|
||||
$query = new ProjectFormTypeQuery();
|
||||
$query->addCustomer($project->getCustomer());
|
||||
$query->setProjectToIgnore($project);
|
||||
$query->setUser($this->getUser());
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
'ignore_project' => $project,
|
||||
'customers' => $project->getCustomer(),
|
||||
'query_builder_for_user' => true,
|
||||
'required' => false,
|
||||
])
|
||||
->setAction($this->generateUrl('admin_project_delete', ['id' => $project->getId()]))
|
||||
|
||||
234
src/Controller/QuickEntryController.php
Normal file
234
src/Controller/QuickEntryController.php
Normal file
@@ -0,0 +1,234 @@
|
||||
<?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\Controller;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Form\QuickEntryForm;
|
||||
use App\Model\QuickEntryModel;
|
||||
use App\Model\QuickEntryWeek;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Timesheet\TimesheetService;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* Controller used to enter times in weekly form.
|
||||
*
|
||||
* @Route(path="/quick_entry")
|
||||
* @Security("is_granted('view_own_timesheet')")
|
||||
*/
|
||||
class QuickEntryController extends AbstractController
|
||||
{
|
||||
private $configuration;
|
||||
private $timesheetService;
|
||||
private $repository;
|
||||
|
||||
public function __construct(SystemConfiguration $configuration, TimesheetService $timesheetService, TimesheetRepository $repository)
|
||||
{
|
||||
$this->configuration = $configuration;
|
||||
$this->timesheetService = $timesheetService;
|
||||
$this->repository = $repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{begin}", name="quick_entry", methods={"GET", "POST"})
|
||||
* @Security("is_granted('edit_own_timesheet')")
|
||||
*/
|
||||
public function quickEntry(Request $request, ?string $begin = null)
|
||||
{
|
||||
$mode = $this->timesheetService->getActiveTrackingMode();
|
||||
|
||||
if (!$mode->canEditDuration() && !$mode->canEditEnd()) {
|
||||
$this->flashError('Not allowed');
|
||||
|
||||
return $this->redirectToRoute('homepage');
|
||||
}
|
||||
|
||||
$factory = $this->getDateTimeFactory();
|
||||
if ($begin === null) {
|
||||
$begin = $factory->createDateTime();
|
||||
} else {
|
||||
$begin = $factory->createDateTime($begin);
|
||||
}
|
||||
|
||||
$startWeek = $factory->getStartOfWeek($begin);
|
||||
$endWeek = $factory->getEndOfWeek($begin);
|
||||
$user = $this->getUser();
|
||||
|
||||
$tmpDay = clone $startWeek;
|
||||
$week = [];
|
||||
while ($tmpDay < $endWeek) {
|
||||
$nextDay = clone $tmpDay;
|
||||
$week[$nextDay->format('Y-m-d')] = ['day' => $nextDay];
|
||||
$tmpDay = $tmpDay->modify('+1 day');
|
||||
}
|
||||
|
||||
$query = new TimesheetQuery();
|
||||
$query->setBegin($startWeek);
|
||||
$query->setEnd($endWeek);
|
||||
$query->setName('quickEntryForm');
|
||||
$query->setUser($this->getUser());
|
||||
|
||||
$result = $this->repository->getTimesheetResult($query);
|
||||
|
||||
$rows = [];
|
||||
/** @var Timesheet $timesheet */
|
||||
foreach ($result->getResults(true) as $timesheet) {
|
||||
$i = 0;
|
||||
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
|
||||
$day = $timesheet->getBegin()->format('Y-m-d');
|
||||
|
||||
while (\array_key_exists($id, $rows) && \array_key_exists('entry', $rows[$id]['days'][$day])) {
|
||||
$i++;
|
||||
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId() . '_' . $i;
|
||||
}
|
||||
|
||||
if (!\array_key_exists($id, $rows)) {
|
||||
$rows[$id] = [
|
||||
'days' => $week,
|
||||
'project' => $timesheet->getProject(),
|
||||
'activity' => $timesheet->getActivity()
|
||||
];
|
||||
}
|
||||
|
||||
$rows[$id]['days'][$day]['entry'] = $timesheet;
|
||||
}
|
||||
|
||||
ksort($rows);
|
||||
|
||||
// attach recent activities
|
||||
$timesheets = $this->repository->getRecentActivities($this->getUser(), null, 5);
|
||||
foreach ($timesheets as $timesheet) {
|
||||
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
|
||||
if (\array_key_exists($id, $rows)) {
|
||||
continue;
|
||||
}
|
||||
$rows[$id] = [
|
||||
'days' => $week,
|
||||
'project' => $timesheet->getProject(),
|
||||
'activity' => $timesheet->getActivity()
|
||||
];
|
||||
}
|
||||
|
||||
$beginTime = $this->configuration->getTimesheetDefaultBeginTime();
|
||||
|
||||
/** @var QuickEntryModel[] $models */
|
||||
$models = [];
|
||||
foreach ($rows as $id => $row) {
|
||||
$model = new QuickEntryModel($user, $row['project'], $row['activity']);
|
||||
foreach ($row['days'] as $dayId => $day) {
|
||||
if (!\array_key_exists('entry', $day)) {
|
||||
$tmp = new Timesheet();
|
||||
$tmp->setUser($user);
|
||||
$tmp->setProject($row['project']);
|
||||
$tmp->setActivity($row['activity']);
|
||||
$tmp->setBegin(clone $day['day']);
|
||||
$tmp->getBegin()->modify($beginTime);
|
||||
$model->addTimesheet($tmp);
|
||||
} else {
|
||||
$model->addTimesheet($day['entry']);
|
||||
}
|
||||
}
|
||||
$models[] = $model;
|
||||
}
|
||||
|
||||
// create prototype model
|
||||
$empty = new QuickEntryModel($user);
|
||||
foreach ($week as $dayId => $day) {
|
||||
$tmp = new Timesheet();
|
||||
$tmp->setUser($user);
|
||||
$tmp->setBegin(clone $day['day']);
|
||||
$tmp->getBegin()->modify($beginTime);
|
||||
$empty->addTimesheet($tmp);
|
||||
}
|
||||
|
||||
// add empty rows for simpler starting
|
||||
$minRows = 3;
|
||||
if (\count($models) < $minRows) {
|
||||
$newRows = $minRows - \count($models);
|
||||
for ($a = 0; $a < $newRows; $a++) {
|
||||
$model = new QuickEntryModel();
|
||||
foreach ($week as $dayId => $day) {
|
||||
$tmp = new Timesheet();
|
||||
$tmp->setUser($user);
|
||||
$tmp->setBegin(clone $day['day']);
|
||||
$tmp->getBegin()->modify($beginTime);
|
||||
$model->addTimesheet($tmp);
|
||||
}
|
||||
|
||||
$models[] = $model;
|
||||
}
|
||||
}
|
||||
|
||||
$formModel = new QuickEntryWeek($startWeek, $models);
|
||||
|
||||
$form = $this->createForm(QuickEntryForm::class, $formModel, [
|
||||
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
|
||||
'prototype_data' => $empty,
|
||||
]);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
/** @var QuickEntryWeek $data */
|
||||
$data = $form->getData();
|
||||
|
||||
$saveTimesheets = [];
|
||||
$deleteTimesheets = [];
|
||||
|
||||
foreach ($data->getRows() as $tmpModel) {
|
||||
foreach ($tmpModel->getTimesheets() as $timesheet) {
|
||||
if ($timesheet->getId() !== null) {
|
||||
if ($timesheet->getDuration(false) === null || $timesheet->getEnd() === null) {
|
||||
$deleteTimesheets[] = $timesheet;
|
||||
} else {
|
||||
$saveTimesheets[] = $timesheet;
|
||||
}
|
||||
} else {
|
||||
if ($timesheet->getDuration() !== null) {
|
||||
$saveTimesheets[] = $timesheet;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->isGranted('delete_own_timesheet') && \count($deleteTimesheets) > 0) {
|
||||
try {
|
||||
$this->timesheetService->deleteMultipleTimesheets($deleteTimesheets);
|
||||
|
||||
return $this->redirectToRoute('quick_entry', ['begin' => $begin->format('Y-m-d')]);
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashError('action.delete.error');
|
||||
$this->logException($ex);
|
||||
}
|
||||
}
|
||||
|
||||
if (\count($saveTimesheets) > 0) {
|
||||
try {
|
||||
$this->timesheetService->updateMultipleTimesheets($saveTimesheets);
|
||||
|
||||
return $this->redirectToRoute('quick_entry', ['begin' => $begin->format('Y-m-d')]);
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashError('action.update.error');
|
||||
$this->logException($ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('quick-entry/index.html.twig', [
|
||||
'days' => $week,
|
||||
'week' => $rows,
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -371,7 +371,7 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
|
||||
{
|
||||
$this->begin = $begin;
|
||||
$this->timezone = $begin->getTimezone()->getName();
|
||||
// make sure that the original date is always
|
||||
// make sure that the original date is always kept in UTC
|
||||
$this->date = new DateTime($begin->format('Y-m-d 00:00:00'), new DateTimeZone('UTC'));
|
||||
|
||||
return $this;
|
||||
@@ -421,12 +421,13 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
|
||||
/**
|
||||
* Do not rely on the results of this method for running records.
|
||||
*
|
||||
* @param bool $calculate
|
||||
* @return int|null
|
||||
*/
|
||||
public function getDuration(): ?int
|
||||
public function getDuration(bool $calculate = true): ?int
|
||||
{
|
||||
// only auto calculate if manually set duration is null - the result is important for eg. validations
|
||||
if ($this->duration === null && $this->begin !== null && $this->end !== null) {
|
||||
if ($calculate && $this->duration === null && $this->begin !== null && $this->end !== null) {
|
||||
return $this->end->getTimestamp() - $this->begin->getTimestamp();
|
||||
}
|
||||
|
||||
|
||||
29
src/EventSubscriber/Actions/QuickEntrySubscriber.php
Normal file
29
src/EventSubscriber/Actions/QuickEntrySubscriber.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\EventSubscriber\Actions;
|
||||
|
||||
use App\Event\PageActionsEvent;
|
||||
|
||||
class QuickEntrySubscriber extends AbstractActionsSubscriber
|
||||
{
|
||||
public static function getActionName(): string
|
||||
{
|
||||
return 'weekly_times';
|
||||
}
|
||||
|
||||
public function onActions(PageActionsEvent $event): void
|
||||
{
|
||||
if ($this->isGranted('view_own_timesheet')) {
|
||||
$event->addBack($this->path('timesheet'));
|
||||
}
|
||||
|
||||
$event->addHelp($this->documentationLink('weekly-times.html'));
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ class TimesheetsSubscriber extends AbstractActionsSubscriber
|
||||
|
||||
if ($this->isGranted('create_own_timesheet')) {
|
||||
$event->addCreate($this->path('timesheet_create'));
|
||||
$event->addAction('quick_entry', ['url' => $this->path('quick_entry'), 'class' => 'create-ts', 'icon' => 'weekly-times']);
|
||||
}
|
||||
|
||||
$event->addHelp($this->documentationLink('timesheet.html'));
|
||||
|
||||
@@ -12,8 +12,6 @@ namespace App\EventSubscriber;
|
||||
use App\Event\ConfigureMainMenuEvent;
|
||||
use App\Twig\IconExtension;
|
||||
use App\Utils\MenuItemModel;
|
||||
use KevinPapst\AdminLTEBundle\Event\SidebarMenuEvent;
|
||||
use KevinPapst\AdminLTEBundle\Model\MenuItemInterface;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
@@ -22,19 +20,11 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
*/
|
||||
final class MenuSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
/**
|
||||
* @var AuthorizationCheckerInterface
|
||||
*/
|
||||
private $security;
|
||||
/**
|
||||
* @var IconExtension
|
||||
*/
|
||||
private $icons;
|
||||
|
||||
public function __construct(AuthorizationCheckerInterface $security)
|
||||
{
|
||||
$this->security = $security;
|
||||
$this->icons = new IconExtension();
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
@@ -52,67 +42,62 @@ final class MenuSubscriber implements EventSubscriberInterface
|
||||
return;
|
||||
}
|
||||
|
||||
$this->configureMainMenu($event->getMenu());
|
||||
$this->configureAdminMenu($event->getAdminMenu());
|
||||
$this->configureSystemMenu($event->getSystemMenu());
|
||||
}
|
||||
$icons = new IconExtension();
|
||||
|
||||
private function configureMainMenu(SidebarMenuEvent $menu)
|
||||
{
|
||||
$auth = $this->security;
|
||||
// ------------------- main menu -------------------
|
||||
$menu = $event->getMenu();
|
||||
|
||||
if ($auth->isGranted('view_own_timesheet')) {
|
||||
$timesheets = new MenuItemModel('timesheet', 'menu.timesheet', 'timesheet', [], $this->getIcon('timesheet'));
|
||||
$timesheets->setChildRoutes(['timesheet_export', 'timesheet_edit', 'timesheet_create', 'timesheet_multi_update']);
|
||||
$timesheets = new MenuItemModel('timesheet', 'menu.timesheet', 'timesheet', [], $icons->icon('timesheet'));
|
||||
$timesheets->setChildRoutes(['timesheet_export', 'timesheet_edit', 'timesheet_create', 'timesheet_multi_update', 'quick_entry']);
|
||||
$menu->addItem($timesheets);
|
||||
|
||||
$menu->addItem(
|
||||
new MenuItemModel('calendar', 'calendar.title', 'calendar', [], $this->getIcon('calendar'))
|
||||
new MenuItemModel('calendar', 'calendar.title', 'calendar', [], $icons->icon('calendar'))
|
||||
);
|
||||
}
|
||||
|
||||
if ($auth->isGranted('view_invoice')) {
|
||||
$invoice = new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], $this->getIcon('invoice'));
|
||||
$invoice = new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], $icons->icon('invoice'));
|
||||
$invoice->setChildRoutes(['admin_invoice_template', 'admin_invoice_template_edit', 'admin_invoice_template_create', 'admin_invoice_template_copy', 'admin_invoice_list', 'admin_invoice_document_upload']);
|
||||
$menu->addItem($invoice);
|
||||
}
|
||||
|
||||
if ($auth->isGranted('create_export')) {
|
||||
$menu->addItem(
|
||||
new MenuItemModel('export', 'menu.export', 'export', [], $this->getIcon('export'))
|
||||
new MenuItemModel('export', 'menu.export', 'export', [], $icons->icon('export'))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function configureAdminMenu(MenuItemInterface $menu)
|
||||
{
|
||||
$auth = $this->security;
|
||||
// ------------------- admin menu -------------------
|
||||
$menu = $event->getAdminMenu();
|
||||
|
||||
if ($auth->isGranted('view_other_timesheet')) {
|
||||
$timesheets = new MenuItemModel('timesheet_admin', 'menu.admin_timesheet', 'admin_timesheet', [], $this->getIcon('timesheet-team'));
|
||||
$timesheets = new MenuItemModel('timesheet_admin', 'menu.admin_timesheet', 'admin_timesheet', [], $icons->icon('timesheet-team'));
|
||||
$timesheets->setChildRoutes(['admin_timesheet_export', 'admin_timesheet_edit', 'admin_timesheet_create', 'admin_timesheet_multi_update']);
|
||||
$menu->addChild($timesheets);
|
||||
}
|
||||
|
||||
if ($auth->isGranted('view_reporting')) {
|
||||
$reporting = new MenuItemModel('reporting', 'menu.reporting', 'reporting', [], $this->getIcon('reporting'));
|
||||
$reporting = new MenuItemModel('reporting', 'menu.reporting', 'reporting', [], $icons->icon('reporting'));
|
||||
$reporting->setChildRoutes(['report_user_week', 'report_user_month', 'report_weekly_users', 'report_monthly_users', 'report_project_view']);
|
||||
$menu->addChild($reporting);
|
||||
}
|
||||
|
||||
if ($auth->isGranted('view_customer') || $auth->isGranted('view_teamlead_customer') || $auth->isGranted('view_team_customer')) {
|
||||
$customers = new MenuItemModel('customer_admin', 'menu.admin_customer', 'admin_customer', [], $this->getIcon('customer'));
|
||||
$customers = new MenuItemModel('customer_admin', 'menu.admin_customer', 'admin_customer', [], $icons->icon('customer'));
|
||||
$customers->setChildRoutes(['admin_customer_create', 'admin_customer_permissions', 'customer_details', 'admin_customer_edit', 'admin_customer_delete']);
|
||||
$menu->addChild($customers);
|
||||
}
|
||||
|
||||
if ($auth->isGranted('view_project') || $auth->isGranted('view_teamlead_project') || $auth->isGranted('view_team_project')) {
|
||||
$projects = new MenuItemModel('project_admin', 'menu.admin_project', 'admin_project', [], $this->getIcon('project'));
|
||||
$projects = new MenuItemModel('project_admin', 'menu.admin_project', 'admin_project', [], $icons->icon('project'));
|
||||
$projects->setChildRoutes(['admin_project_permissions', 'admin_project_create', 'project_details', 'admin_project_edit', 'admin_project_delete']);
|
||||
$menu->addChild($projects);
|
||||
}
|
||||
|
||||
if ($auth->isGranted('view_activity') || $auth->isGranted('view_teamlead_activity') || $auth->isGranted('view_team_activity')) {
|
||||
$activities = new MenuItemModel('activity_admin', 'menu.admin_activity', 'admin_activity', [], $this->getIcon('activity'));
|
||||
$activities = new MenuItemModel('activity_admin', 'menu.admin_activity', 'admin_activity', [], $icons->icon('activity'));
|
||||
$activities->setChildRoutes(['admin_activity_create', 'activity_details', 'admin_activity_edit', 'admin_activity_delete']);
|
||||
$menu->addChild($activities);
|
||||
}
|
||||
@@ -122,50 +107,43 @@ final class MenuSubscriber implements EventSubscriberInterface
|
||||
new MenuItemModel('tags', 'menu.tags', 'tags', [], 'fas fa-tags')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function configureSystemMenu(MenuItemInterface $menu)
|
||||
{
|
||||
$auth = $this->security;
|
||||
// ------------------- system menu -------------------
|
||||
$menu = $event->getSystemMenu();
|
||||
|
||||
if ($auth->isGranted('view_user')) {
|
||||
$users = new MenuItemModel('user_admin', 'menu.admin_user', 'admin_user', [], $this->getIcon('users'));
|
||||
$users = new MenuItemModel('user_admin', 'menu.admin_user', 'admin_user', [], $icons->icon('users'));
|
||||
$users->setChildRoutes(['admin_user_create', 'admin_user_delete', 'user_profile', 'user_profile_edit', 'user_profile_password', 'user_profile_api_token', 'user_profile_roles', 'user_profile_teams', 'user_profile_preferences']);
|
||||
$menu->addChild($users);
|
||||
}
|
||||
|
||||
if ($auth->isGranted('role_permissions')) {
|
||||
$users = new MenuItemModel('admin_user_permissions', 'profile.roles', 'admin_user_permissions', [], $this->getIcon('permissions'));
|
||||
$users = new MenuItemModel('admin_user_permissions', 'profile.roles', 'admin_user_permissions', [], $icons->icon('permissions'));
|
||||
$menu->addChild($users);
|
||||
}
|
||||
|
||||
if ($auth->isGranted('view_team')) {
|
||||
$teams = new MenuItemModel('user_team', 'menu.admin_team', 'admin_team', [], $this->getIcon('team'));
|
||||
$teams = new MenuItemModel('user_team', 'menu.admin_team', 'admin_team', [], $icons->icon('team'));
|
||||
$teams->setChildRoutes(['admin_team_create', 'admin_team_edit']);
|
||||
$menu->addChild($teams);
|
||||
}
|
||||
|
||||
if ($auth->isGranted('plugins')) {
|
||||
$menu->addChild(
|
||||
new MenuItemModel('plugins', 'menu.plugin', 'plugins', [], $this->getIcon('plugin'))
|
||||
new MenuItemModel('plugins', 'menu.plugin', 'plugins', [], $icons->icon('plugin'))
|
||||
);
|
||||
}
|
||||
|
||||
if ($auth->isGranted('system_configuration')) {
|
||||
$menu->addChild(
|
||||
new MenuItemModel('system_configuration', 'menu.system_configuration', 'system_configuration', [], $this->getIcon('configuration'))
|
||||
new MenuItemModel('system_configuration', 'menu.system_configuration', 'system_configuration', [], $icons->icon('configuration'))
|
||||
);
|
||||
}
|
||||
|
||||
if ($auth->isGranted('system_information')) {
|
||||
$menu->addChild(
|
||||
new MenuItemModel('doctor', 'menu.doctor', 'doctor', [], $this->getIcon('doctor'))
|
||||
new MenuItemModel('doctor', 'menu.doctor', 'doctor', [], $icons->icon('doctor'))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function getIcon(string $icon)
|
||||
{
|
||||
return $this->icons->icon($icon, $icon);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,15 @@ class DurationStringToSecondsTransformer implements DataTransformerInterface
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->formatter->parseDurationString($formatToInt);
|
||||
$seconds = $this->formatter->parseDurationString($formatToInt);
|
||||
|
||||
// DateTime throws if a duration with too many seconds is passed and an amount of so
|
||||
// many seconds is likely not required in a time-tracking application ;-)
|
||||
if ($seconds > 315360000000000) {
|
||||
throw new TransformationFailedException('Maximum duration exceeded.');
|
||||
}
|
||||
|
||||
return $seconds;
|
||||
} catch (\Exception $e) {
|
||||
throw new TransformationFailedException($e->getMessage());
|
||||
}
|
||||
|
||||
@@ -68,13 +68,23 @@ class SelectWithApiDataExtension extends AbstractTypeExtension
|
||||
$apiData['route_params'] = [];
|
||||
}
|
||||
|
||||
$formPrefix = $form->getParent()->getName();
|
||||
if (!empty($formPrefix)) {
|
||||
$formPrefix .= '_';
|
||||
$formPrefixes = [];
|
||||
$parent = $form->getParent();
|
||||
do {
|
||||
$formPrefixes[] = $parent->getName();
|
||||
} while (($parent = $parent->getParent()) !== null);
|
||||
|
||||
$formPrefix = implode('_', array_reverse($formPrefixes));
|
||||
|
||||
$formField = $formPrefix;
|
||||
if (!empty($formField)) {
|
||||
$formField .= '_';
|
||||
}
|
||||
$formField .= $apiData['select'];
|
||||
|
||||
$view->vars['attr'] = array_merge($view->vars['attr'], [
|
||||
'data-related-select' => $formPrefix . $apiData['select'],
|
||||
'data-form-prefix' => $formPrefix,
|
||||
'data-related-select' => $formField,
|
||||
'data-api-url' => $this->router->generate($apiData['route'], $apiData['route_params']),
|
||||
]);
|
||||
|
||||
|
||||
@@ -17,11 +17,7 @@ use App\Form\Type\CustomerType;
|
||||
use App\Form\Type\DescriptionType;
|
||||
use App\Form\Type\ProjectType;
|
||||
use App\Form\Type\TagsType;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\ActivityFormTypeQuery;
|
||||
use App\Repository\Query\CustomerFormTypeQuery;
|
||||
use App\Repository\Query\ProjectFormTypeQuery;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
@@ -29,18 +25,16 @@ use Symfony\Component\Form\FormEvents;
|
||||
|
||||
/**
|
||||
* Helper functions to manage dependent customer-project-activity fields.
|
||||
*
|
||||
* If you always want to show the list of all available projects/activities, use the form types directly.
|
||||
*/
|
||||
trait FormTrait
|
||||
{
|
||||
protected function addCustomer(FormBuilderInterface $builder, ?Customer $customer = null)
|
||||
{
|
||||
$builder->add('customer', CustomerType::class, [
|
||||
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
|
||||
$query = new CustomerFormTypeQuery($customer);
|
||||
$query->setUser($builder->getOption('user'));
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
'query_builder_for_user' => true,
|
||||
'customers' => $customer,
|
||||
'data' => $customer ? $customer : '',
|
||||
'required' => false,
|
||||
'placeholder' => '',
|
||||
@@ -49,30 +43,29 @@ trait FormTrait
|
||||
]);
|
||||
}
|
||||
|
||||
protected function addProject(FormBuilderInterface $builder, bool $isNew, ?Project $project = null, ?Customer $customer = null)
|
||||
protected function addProject(FormBuilderInterface $builder, bool $isNew, ?Project $project = null, ?Customer $customer = null, array $options = [])
|
||||
{
|
||||
$builder->add('project', ProjectType::class, [
|
||||
$options = array_merge([
|
||||
'placeholder' => '',
|
||||
'activity_enabled' => true,
|
||||
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
|
||||
$query = new ProjectFormTypeQuery($project, $customer);
|
||||
$query->setUser($builder->getOption('user'));
|
||||
'query_builder_for_user' => true,
|
||||
'join_customer' => true
|
||||
], $options);
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
]);
|
||||
$builder->add('project', ProjectType::class, array_merge($options, [
|
||||
'projects' => $project,
|
||||
'customers' => $customer,
|
||||
]));
|
||||
|
||||
// replaces the project select after submission, to make sure only projects for the selected customer are displayed
|
||||
$builder->addEventListener(
|
||||
FormEvents::PRE_SUBMIT,
|
||||
function (FormEvent $event) use ($builder, $project, $customer, $isNew) {
|
||||
function (FormEvent $event) use ($builder, $project, $customer, $isNew, $options) {
|
||||
$data = $event->getData();
|
||||
$customer = isset($data['customer']) && !empty($data['customer']) ? $data['customer'] : null;
|
||||
$project = isset($data['project']) && !empty($data['project']) ? $data['project'] : $project;
|
||||
|
||||
$event->getForm()->add('project', ProjectType::class, [
|
||||
'placeholder' => '',
|
||||
'activity_enabled' => true,
|
||||
$event->getForm()->add('project', ProjectType::class, array_merge($options, [
|
||||
'group_by' => null,
|
||||
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer, $isNew) {
|
||||
// is there a better wa to prevent starting a record with a hidden project ?
|
||||
@@ -90,44 +83,36 @@ trait FormTrait
|
||||
}
|
||||
$query = new ProjectFormTypeQuery($project, $customer);
|
||||
$query->setUser($builder->getOption('user'));
|
||||
$query->setWithCustomer(true);
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
]);
|
||||
]));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected function addActivity(FormBuilderInterface $builder, ?Activity $activity = null, ?Project $project = null)
|
||||
protected function addActivity(FormBuilderInterface $builder, ?Activity $activity = null, ?Project $project = null, array $options = [])
|
||||
{
|
||||
$builder->add('activity', ActivityType::class, [
|
||||
'placeholder' => '',
|
||||
'query_builder' => function (ActivityRepository $repo) use ($builder, $activity, $project) {
|
||||
$query = new ActivityFormTypeQuery($activity, $project);
|
||||
$query->setUser($builder->getOption('user'));
|
||||
$options = array_merge(['placeholder' => '', 'query_builder_for_user' => true], $options);
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
]);
|
||||
$options['projects'] = $project;
|
||||
$options['activities'] = $activity;
|
||||
|
||||
$builder->add('activity', ActivityType::class, $options);
|
||||
|
||||
// replaces the activity select after submission, to make sure only activities for the selected project are displayed
|
||||
$builder->addEventListener(
|
||||
FormEvents::PRE_SUBMIT,
|
||||
function (FormEvent $event) use ($builder, $activity) {
|
||||
function (FormEvent $event) use ($options) {
|
||||
$data = $event->getData();
|
||||
if (!isset($data['project']) || empty($data['project'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->getForm()->add('activity', ActivityType::class, [
|
||||
'placeholder' => '',
|
||||
'query_builder' => function (ActivityRepository $repo) use ($builder, $data, $activity) {
|
||||
$query = new ActivityFormTypeQuery($activity, $data['project']);
|
||||
$query->setUser($builder->getOption('user'));
|
||||
$options['projects'] = $data['project'];
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
]);
|
||||
$event->getForm()->add('activity', ActivityType::class, $options);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,12 +18,7 @@ use App\Form\Type\ProjectType;
|
||||
use App\Form\Type\TagsType;
|
||||
use App\Form\Type\UserType;
|
||||
use App\Form\Type\YesNoType;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\ActivityFormTypeQuery;
|
||||
use App\Repository\Query\CustomerFormTypeQuery;
|
||||
use App\Repository\Query\ProjectFormTypeQuery;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use Doctrine\Common\Collections\Criteria;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
@@ -83,12 +78,8 @@ class TimesheetMultiUpdate extends AbstractType
|
||||
|
||||
$builder
|
||||
->add('customer', CustomerType::class, [
|
||||
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
|
||||
$query = new CustomerFormTypeQuery($customer);
|
||||
$query->setUser($builder->getOption('user'));
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
'query_builder_for_user' => true,
|
||||
'customers' => $customer,
|
||||
'data' => $customer ? $customer : '',
|
||||
'required' => false,
|
||||
'placeholder' => '',
|
||||
@@ -103,28 +94,20 @@ class TimesheetMultiUpdate extends AbstractType
|
||||
$projectOptions['group_by'] = null;
|
||||
}
|
||||
|
||||
$builder
|
||||
->add(
|
||||
'project',
|
||||
ProjectType::class,
|
||||
array_merge($projectOptions, [
|
||||
$builder->add('project', ProjectType::class, array_merge($projectOptions, [
|
||||
'required' => false,
|
||||
'placeholder' => '',
|
||||
'activity_enabled' => true,
|
||||
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
|
||||
$query = new ProjectFormTypeQuery($project, $customer);
|
||||
$query->setUser($builder->getOption('user'));
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
])
|
||||
);
|
||||
'customers' => $customer,
|
||||
'projects' => $project,
|
||||
'query_builder_for_user' => true,
|
||||
]));
|
||||
|
||||
// replaces the project select after submission, to make sure only projects for the selected customer are displayed
|
||||
// TODO replace me with FormTrait
|
||||
$builder->addEventListener(
|
||||
FormEvents::PRE_SUBMIT,
|
||||
function (FormEvent $event) use ($builder, $project, $customer) {
|
||||
function (FormEvent $event) use ($project, $customer) {
|
||||
$data = $event->getData();
|
||||
$customer = isset($data['customer']) && !empty($data['customer']) ? $data['customer'] : null;
|
||||
$project = isset($data['project']) && !empty($data['project']) ? $data['project'] : $project;
|
||||
@@ -134,45 +117,37 @@ class TimesheetMultiUpdate extends AbstractType
|
||||
'placeholder' => '',
|
||||
'activity_enabled' => true,
|
||||
'group_by' => null,
|
||||
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
|
||||
$query = new ProjectFormTypeQuery($project, $customer);
|
||||
$query->setUser($builder->getOption('user'));
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
'customers' => $customer,
|
||||
'projects' => $project,
|
||||
'query_builder_for_user' => true,
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
$builder
|
||||
->add('activity', ActivityType::class, [
|
||||
$activityOptions = [
|
||||
'required' => false,
|
||||
'placeholder' => '',
|
||||
'query_builder' => function (ActivityRepository $repo) use ($activity, $project) {
|
||||
// TODO respect user (team permission)
|
||||
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $project));
|
||||
},
|
||||
])
|
||||
;
|
||||
'activities' => $activity,
|
||||
'query_builder_for_user' => true,
|
||||
];
|
||||
|
||||
$builder->add('activity', ActivityType::class, array_merge($activityOptions, [
|
||||
'projects' => $project,
|
||||
]));
|
||||
|
||||
// replaces the activity select after submission, to make sure only activities for the selected project are displayed
|
||||
// TODO replace me with FormTrait
|
||||
$builder->addEventListener(
|
||||
FormEvents::PRE_SUBMIT,
|
||||
function (FormEvent $event) use ($activity) {
|
||||
function (FormEvent $event) use ($activityOptions) {
|
||||
$data = $event->getData();
|
||||
if (!isset($data['project']) || empty($data['project'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->getForm()->add('activity', ActivityType::class, [
|
||||
'required' => false,
|
||||
'placeholder' => '',
|
||||
'query_builder' => function (ActivityRepository $repo) use ($data, $activity) {
|
||||
// TODO respect user (team permission)
|
||||
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $data['project']));
|
||||
},
|
||||
]);
|
||||
$event->getForm()->add('activity', ActivityType::class, array_merge($activityOptions, [
|
||||
'projects' => $data['project'],
|
||||
]));
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Form\Type\CustomerType;
|
||||
use App\Form\Type\DateTimePickerType;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\Query\CustomerFormTypeQuery;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
@@ -90,12 +88,8 @@ class ProjectEditForm extends AbstractType
|
||||
]))
|
||||
->add('customer', CustomerType::class, [
|
||||
'placeholder' => (null === $id && null === $customer) ? '' : false,
|
||||
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
|
||||
$query = new CustomerFormTypeQuery($customer);
|
||||
$query->setUser($builder->getOption('user'));
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
},
|
||||
'customers' => $customer,
|
||||
'query_builder_for_user' => true,
|
||||
]);
|
||||
|
||||
$this->addCommonFields($builder, $options);
|
||||
|
||||
89
src/Form/QuickEntryForm.php
Normal file
89
src/Form/QuickEntryForm.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?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;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Form\Type\QuickEntryWeekType;
|
||||
use App\Form\Type\WeekPickerType;
|
||||
use App\Model\QuickEntryWeek;
|
||||
use App\Validator\Constraints\QuickEntryModel;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\All;
|
||||
use Symfony\Component\Validator\Constraints\Valid;
|
||||
|
||||
class QuickEntryForm extends AbstractType
|
||||
{
|
||||
private $configuration;
|
||||
|
||||
public function __construct(SystemConfiguration $configuration)
|
||||
{
|
||||
$this->configuration = $configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$startDate = new \DateTime();
|
||||
if ($builder->getData() !== null) {
|
||||
/** @var QuickEntryWeek $data */
|
||||
$data = $builder->getData();
|
||||
$startDate = $data->getDate();
|
||||
}
|
||||
|
||||
$builder->add('date', WeekPickerType::class, [
|
||||
'model_timezone' => $options['timezone'],
|
||||
'view_timezone' => $options['timezone'],
|
||||
'start_date' => $options['start_date'],
|
||||
'label' => false,
|
||||
]);
|
||||
|
||||
$builder->add('rows', CollectionType::class, [
|
||||
'label' => false,
|
||||
'entry_type' => QuickEntryWeekType::class,
|
||||
'entry_options' => [
|
||||
'label' => false,
|
||||
'duration_minutes' => $this->configuration->getTimesheetIncrementDuration(),
|
||||
'start_date' => $startDate,
|
||||
'empty_data' => function (FormInterface $form) use ($options) {
|
||||
return clone $options['prototype_data'];
|
||||
},
|
||||
'prototype_data' => clone $options['prototype_data'],
|
||||
],
|
||||
'prototype_data' => $options['prototype_data'],
|
||||
'allow_add' => true,
|
||||
'constraints' => [
|
||||
new Valid(),
|
||||
new All(['constraints' => [new QuickEntryModel()]])
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'csrf_protection' => true,
|
||||
'csrf_field_name' => '_token',
|
||||
'csrf_token_id' => 'timesheet_quick_edit',
|
||||
'data_class' => QuickEntryWeek::class,
|
||||
'timezone' => date_default_timezone_get(),
|
||||
'start_date' => new \DateTime(),
|
||||
'prototype_data' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use App\Repository\ActivityRepository;
|
||||
use App\Repository\Query\ActivityFormTypeQuery;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
@@ -78,10 +79,30 @@ class ActivityType extends AbstractType
|
||||
'choice_label' => [$this, 'choiceLabel'],
|
||||
'group_by' => [$this, 'groupBy'],
|
||||
'choice_attr' => [$this, 'choiceAttr'],
|
||||
'query_builder' => function (ActivityRepository $repo) {
|
||||
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery());
|
||||
},
|
||||
'query_builder_for_user' => true,
|
||||
// @var Project|Project[]|int|int[]|null
|
||||
'projects' => null,
|
||||
// @var Activity|Activity[]|int|int[]|null
|
||||
'activities' => null,
|
||||
// @var Activity|null
|
||||
'ignore_activity' => null,
|
||||
]);
|
||||
|
||||
$resolver->setDefault('query_builder', function (Options $options) {
|
||||
return function (ActivityRepository $repo) use ($options) {
|
||||
$query = new ActivityFormTypeQuery($options['activities'], $options['projects']);
|
||||
|
||||
if (true === $options['query_builder_for_user']) {
|
||||
$query->setUser($options['user']);
|
||||
}
|
||||
|
||||
if (null !== $options['ignore_activity']) {
|
||||
$query->setActivityToIgnore($options['ignore_activity']);
|
||||
}
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,15 +44,23 @@ class CustomerType extends AbstractType
|
||||
'end_date_param' => '%end%',
|
||||
'ignore_date' => false,
|
||||
'project_visibility' => ProjectQuery::SHOW_VISIBLE,
|
||||
// @var Customer|null
|
||||
'ignore_customer' => null,
|
||||
// @var Customer|Customer[]|null
|
||||
'customers' => null,
|
||||
]);
|
||||
|
||||
$resolver->setDefault('query_builder', function (Options $options) {
|
||||
return function (CustomerRepository $repo) use ($options) {
|
||||
$query = new CustomerFormTypeQuery();
|
||||
$query = new CustomerFormTypeQuery($options['customers']);
|
||||
if (true === $options['query_builder_for_user']) {
|
||||
$query->setUser($options['user']);
|
||||
}
|
||||
|
||||
if (null !== $options['ignore_customer']) {
|
||||
$query->setCustomerToIgnore($options['ignore_customer']);
|
||||
}
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -38,6 +38,12 @@ class DurationType extends AbstractType
|
||||
|
||||
public function buildView(FormView $view, FormInterface $form, array $options)
|
||||
{
|
||||
$class = 'duration-input';
|
||||
if (isset($view->vars['attr']['class'])) {
|
||||
$class .= ' ' . $view->vars['attr']['class'];
|
||||
}
|
||||
$view->vars['attr']['class'] = $class;
|
||||
|
||||
if ($options['preset_hours'] === null || $options['preset_minutes'] === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ class InitialViewType extends AbstractType
|
||||
'dashboard' => 'menu.homepage',
|
||||
'timesheet' => 'menu.timesheet',
|
||||
'calendar' => 'calendar.title',
|
||||
'quick_entry' => 'quick_entry.title',
|
||||
'my_profile' => 'profile.title',
|
||||
'admin_timesheet' => 'menu.admin_timesheet',
|
||||
'invoice' => 'menu.invoice',
|
||||
@@ -49,6 +50,7 @@ class InitialViewType extends AbstractType
|
||||
'admin_customer' => 'view_customer',
|
||||
'admin_project' => 'view_project',
|
||||
'admin_activity' => 'view_activity',
|
||||
'quick_entry' => 'view_own_timesheet',
|
||||
];
|
||||
|
||||
private $voter;
|
||||
|
||||
@@ -68,21 +68,44 @@ class ProjectType extends AbstractType
|
||||
'activity_visibility' => ActivityQuery::SHOW_VISIBLE,
|
||||
'ignore_date' => false,
|
||||
'join_customer' => false,
|
||||
// @var Project|null
|
||||
'ignore_project' => null,
|
||||
// @var Customer|Customer[]|int|int[]|null
|
||||
'customers' => null,
|
||||
// @var Project|Project[]|int|int[]|null
|
||||
'projects' => null,
|
||||
// @var DateTime|null
|
||||
'project_date_start' => null,
|
||||
// @var DateTime|null
|
||||
'project_date_end' => null,
|
||||
]);
|
||||
|
||||
$resolver->setDefault('query_builder', function (Options $options) {
|
||||
return function (ProjectRepository $repo) use ($options) {
|
||||
$query = new ProjectFormTypeQuery();
|
||||
$query = new ProjectFormTypeQuery($options['projects'], $options['customers']);
|
||||
if (true === $options['query_builder_for_user']) {
|
||||
$query->setUser($options['user']);
|
||||
}
|
||||
|
||||
if (true === $options['ignore_date']) {
|
||||
$query->setIgnoreDate(true);
|
||||
} else {
|
||||
if ($options['project_date_start'] !== null) {
|
||||
$query->setProjectStart($options['project_date_start']);
|
||||
}
|
||||
if ($options['project_date_end'] !== null) {
|
||||
$query->setProjectEnd($options['project_date_end']);
|
||||
}
|
||||
}
|
||||
|
||||
if (true === $options['join_customer']) {
|
||||
$query->setWithCustomer(true);
|
||||
}
|
||||
|
||||
if (null !== $options['ignore_project']) {
|
||||
$query->setProjectToIgnore($options['ignore_project']);
|
||||
}
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
};
|
||||
});
|
||||
|
||||
108
src/Form/Type/QuickEntryTimesheetType.php
Normal file
108
src/Form/Type/QuickEntryTimesheetType.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?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\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
class QuickEntryTimesheetType extends AbstractType
|
||||
{
|
||||
private $security;
|
||||
|
||||
public function __construct(Security $security)
|
||||
{
|
||||
$this->security = $security;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$durationOptions = [
|
||||
'label' => false,
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'placeholder' => '0:00',
|
||||
],
|
||||
];
|
||||
|
||||
$duration = $options['duration_minutes'];
|
||||
if ($duration !== null && (int) $duration > 0) {
|
||||
$durationOptions = array_merge($durationOptions, [
|
||||
'preset_minutes' => $duration
|
||||
]);
|
||||
}
|
||||
|
||||
$duration = $options['duration_hours'];
|
||||
if ($duration !== null && (int) $duration > 0) {
|
||||
$durationOptions = array_merge($durationOptions, [
|
||||
'preset_hours' => $duration,
|
||||
]);
|
||||
}
|
||||
|
||||
$builder->add('duration', DurationType::class, $durationOptions);
|
||||
|
||||
$builder->addEventListener(
|
||||
FormEvents::POST_SET_DATA,
|
||||
function (FormEvent $event) use ($durationOptions) {
|
||||
/** @var Timesheet|null $data */
|
||||
$data = $event->getData();
|
||||
if (null === $data || null === $data->getEnd()) {
|
||||
$event->getForm()->get('duration')->setData(null);
|
||||
}
|
||||
|
||||
if (null !== $data && !$this->security->isGranted('edit', $data)) {
|
||||
$event->getForm()->add('duration', DurationType::class, array_merge(['disabled' => true], $durationOptions));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// make sure that duration is mapped back to end field
|
||||
$builder->addEventListener(
|
||||
FormEvents::SUBMIT,
|
||||
function (FormEvent $event) {
|
||||
/** @var Timesheet $data */
|
||||
$data = $event->getData();
|
||||
$duration = $data->getDuration(false);
|
||||
try {
|
||||
if (null !== $duration) {
|
||||
$end = clone $data->getBegin();
|
||||
$end->modify('+ ' . $duration . ' seconds');
|
||||
$data->setEnd($end);
|
||||
} else {
|
||||
$data->setDuration(null);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$event->getForm()->addError(new FormError($e->getMessage()));
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => Timesheet::class,
|
||||
'timezone' => date_default_timezone_get(),
|
||||
'duration_minutes' => null,
|
||||
'duration_hours' => 10,
|
||||
]);
|
||||
}
|
||||
}
|
||||
180
src/Form/Type/QuickEntryWeekType.php
Normal file
180
src/Form/Type/QuickEntryWeekType.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?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\Model\QuickEntryModel;
|
||||
use App\Validator\Constraints\QuickEntryTimesheet;
|
||||
use DateTime;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\CallbackTransformer;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\All;
|
||||
use Symfony\Component\Validator\Constraints\Valid;
|
||||
|
||||
class QuickEntryWeekType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$projectOptions = [
|
||||
'label' => false,
|
||||
'required' => false,
|
||||
'join_customer' => true,
|
||||
'query_builder_for_user' => true,
|
||||
'placeholder' => '',
|
||||
'activity_enabled' => true
|
||||
];
|
||||
|
||||
$builder->add('project', ProjectType::class, $projectOptions);
|
||||
|
||||
$projectFunction = function (FormEvent $event) use ($projectOptions) {
|
||||
/** @var QuickEntryModel|null $data */
|
||||
$data = $event->getData();
|
||||
if ($data === null || $data->getProject() === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$begin = clone $data->getFirstEntry()->getBegin();
|
||||
$begin->setTime(0, 0, 0);
|
||||
$projectOptions['project_date_start'] = $begin;
|
||||
|
||||
$end = clone $data->getLatestEntry()->getBegin();
|
||||
$end->setTime(23, 59, 59);
|
||||
$projectOptions['project_date_end'] = $begin;
|
||||
$projectOptions['projects'] = [$data->getProject()];
|
||||
|
||||
$event->getForm()->add('project', ProjectType::class, $projectOptions);
|
||||
};
|
||||
|
||||
$builder->addEventListener(FormEvents::PRE_SET_DATA, $projectFunction);
|
||||
|
||||
$activityOptions = [
|
||||
'label' => false,
|
||||
'required' => false,
|
||||
'placeholder' => '',
|
||||
'query_builder_for_user' => true,
|
||||
];
|
||||
|
||||
$builder->add('activity', ActivityType::class, $activityOptions);
|
||||
|
||||
$activityFunction = function (FormEvent $event) use ($activityOptions) {
|
||||
/** @var QuickEntryModel|null $data */
|
||||
$data = $event->getData();
|
||||
if ($data === null || $data->getActivity() === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$activityOptions['activities'] = [$data->getActivity()];
|
||||
$activityOptions['projects'] = [$data->getProject()];
|
||||
|
||||
$event->getForm()->add('activity', ActivityType::class, $activityOptions);
|
||||
};
|
||||
|
||||
$builder->addEventListener(FormEvents::PRE_SET_DATA, $activityFunction);
|
||||
|
||||
$builder->add('timesheets', CollectionType::class, [
|
||||
'entry_type' => QuickEntryTimesheetType::class,
|
||||
'label' => false,
|
||||
'entry_options' => [
|
||||
'label' => false,
|
||||
'compound' => true,
|
||||
'timezone' => $options['timezone'],
|
||||
'duration_minutes' => $options['duration_minutes'],
|
||||
'duration_hours' => $options['duration_hours'],
|
||||
],
|
||||
'allow_add' => true,
|
||||
'constraints' => [
|
||||
new Valid(),
|
||||
new All(['constraints' => [new QuickEntryTimesheet()]])
|
||||
],
|
||||
]);
|
||||
|
||||
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options) {
|
||||
if ($event->getData() === null) {
|
||||
$event->setData(clone $options['prototype_data']);
|
||||
}
|
||||
});
|
||||
|
||||
$builder->addModelTransformer(new CallbackTransformer(
|
||||
function ($transformValue) use ($options) {
|
||||
/** @var QuickEntryModel|null $transformValue */
|
||||
if ($transformValue === null || $transformValue->isPrototype()) {
|
||||
return $transformValue;
|
||||
}
|
||||
|
||||
$project = $transformValue->getProject();
|
||||
$activity = $transformValue->getActivity();
|
||||
|
||||
// this case needs to be handled by the validator
|
||||
if ($project === null || $activity === null) {
|
||||
return $transformValue;
|
||||
}
|
||||
|
||||
foreach ($transformValue->getTimesheets() as $timesheet) {
|
||||
$timesheet->setUser($transformValue->getUser() ?? $options['user']);
|
||||
$timesheet->setProject($project);
|
||||
$timesheet->setActivity($activity);
|
||||
}
|
||||
|
||||
return $transformValue;
|
||||
},
|
||||
function ($reverseTransformValue) {
|
||||
return $reverseTransformValue;
|
||||
}
|
||||
));
|
||||
|
||||
// make sure that duration is mapped back to end field
|
||||
$builder->addEventListener(
|
||||
FormEvents::SUBMIT,
|
||||
function (FormEvent $event) {
|
||||
/** @var QuickEntryModel $data */
|
||||
$data = $event->getData();
|
||||
$newRecords = $data->getNewTimesheet();
|
||||
|
||||
$user = $data->getUser();
|
||||
$project = $data->getProject();
|
||||
$activity = $data->getActivity();
|
||||
|
||||
foreach ($newRecords as $record) {
|
||||
if ($user !== null) {
|
||||
$record->setUser($user);
|
||||
}
|
||||
if ($project !== null) {
|
||||
$record->setProject($project);
|
||||
}
|
||||
if ($activity !== null) {
|
||||
$record->setActivity($activity);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => QuickEntryModel::class,
|
||||
'timezone' => date_default_timezone_get(),
|
||||
'duration_minutes' => null,
|
||||
'duration_hours' => 10,
|
||||
'start_date' => new DateTime(),
|
||||
'prototype_data' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
195
src/Model/QuickEntryModel.php
Normal file
195
src/Model/QuickEntryModel.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?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\Model;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class QuickEntryModel
|
||||
{
|
||||
private $user;
|
||||
private $project;
|
||||
private $activity;
|
||||
/**
|
||||
* @var Timesheet[]
|
||||
*/
|
||||
private $timesheets = [];
|
||||
|
||||
public function __construct(?User $user = null, ?Project $project = null, ?Activity $activity = null)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->project = $project;
|
||||
$this->activity = $activity;
|
||||
}
|
||||
|
||||
public function isPrototype(): bool
|
||||
{
|
||||
if ($this->hasExistingTimesheet()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasNewTimesheet()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->getUser() === null && $this->getProject() === null && $this->getActivity() === null;
|
||||
}
|
||||
|
||||
public function getUser(): ?User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function getProject(): ?Project
|
||||
{
|
||||
return $this->project;
|
||||
}
|
||||
|
||||
public function setProject(?Project $project): void
|
||||
{
|
||||
$this->project = $project;
|
||||
}
|
||||
|
||||
public function getActivity(): ?Activity
|
||||
{
|
||||
return $this->activity;
|
||||
}
|
||||
|
||||
public function setActivity(?Activity $activity): void
|
||||
{
|
||||
$this->activity = $activity;
|
||||
}
|
||||
|
||||
public function hasExistingTimesheet(): bool
|
||||
{
|
||||
foreach ($this->timesheets as $timesheet) {
|
||||
if ($timesheet->getId() !== null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Timesheet[]
|
||||
*/
|
||||
public function getNewTimesheet(): array
|
||||
{
|
||||
$new = [];
|
||||
|
||||
foreach ($this->timesheets as $timesheet) {
|
||||
if ($timesheet->getId() === null && $timesheet->getDuration(false) !== null) {
|
||||
$new[] = $timesheet;
|
||||
}
|
||||
}
|
||||
|
||||
return $new;
|
||||
}
|
||||
|
||||
public function hasNewTimesheet(): bool
|
||||
{
|
||||
return \count($this->getNewTimesheet()) > 0;
|
||||
}
|
||||
|
||||
public function hasTimesheetWithDuration(): bool
|
||||
{
|
||||
foreach ($this->timesheets as $timesheet) {
|
||||
if ($timesheet->getDuration(false) !== null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Timesheet[]
|
||||
*/
|
||||
public function getTimesheets(): array
|
||||
{
|
||||
return $this->timesheets;
|
||||
}
|
||||
|
||||
public function addTimesheet(Timesheet $timesheet): void
|
||||
{
|
||||
$this->timesheets[] = $timesheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Timesheet[] $timesheets
|
||||
*/
|
||||
public function setTimesheets(array $timesheets): void
|
||||
{
|
||||
$this->timesheets = [];
|
||||
foreach ($timesheets as $timesheet) {
|
||||
$this->addTimesheet($timesheet);
|
||||
}
|
||||
}
|
||||
|
||||
public function getLatestEntry(): ?Timesheet
|
||||
{
|
||||
$latest = null;
|
||||
|
||||
foreach ($this->timesheets as $timesheet) {
|
||||
if ($timesheet->getBegin() === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($latest === null) {
|
||||
$latest = $timesheet;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($latest->getBegin() < $timesheet->getBegin()) {
|
||||
$latest = $timesheet;
|
||||
}
|
||||
}
|
||||
|
||||
return $latest;
|
||||
}
|
||||
|
||||
public function getFirstEntry(): ?Timesheet
|
||||
{
|
||||
$first = null;
|
||||
|
||||
foreach ($this->timesheets as $timesheet) {
|
||||
if ($timesheet->getBegin() === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($first === null) {
|
||||
$first = $timesheet;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($first->getBegin() > $timesheet->getBegin()) {
|
||||
$first = $timesheet;
|
||||
}
|
||||
}
|
||||
|
||||
return $first;
|
||||
}
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
$records = $this->timesheets;
|
||||
$this->timesheets = [];
|
||||
|
||||
foreach ($records as $record) {
|
||||
$this->timesheets[] = clone $record;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
src/Model/QuickEntryWeek.php
Normal file
50
src/Model/QuickEntryWeek.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?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\Model;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class QuickEntryWeek
|
||||
{
|
||||
private $date;
|
||||
private $rows;
|
||||
|
||||
/**
|
||||
* @param \DateTime $startDate
|
||||
* @param QuickEntryModel[] $rows
|
||||
*/
|
||||
public function __construct(\DateTime $startDate, array $rows)
|
||||
{
|
||||
$this->date = $startDate;
|
||||
$this->rows = $rows;
|
||||
}
|
||||
|
||||
public function getDate(): \DateTime
|
||||
{
|
||||
return $this->date;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return QuickEntryModel[]
|
||||
*/
|
||||
public function getRows(): array
|
||||
{
|
||||
return $this->rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param QuickEntryModel[] $rows
|
||||
*/
|
||||
public function setRows(array $rows): void
|
||||
{
|
||||
$this->rows = $rows;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\ORMException;
|
||||
use Doctrine\ORM\Query;
|
||||
use Doctrine\ORM\Query\Expr\Andx;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Pagerfanta\Pagerfanta;
|
||||
|
||||
@@ -160,16 +161,26 @@ class ActivityRepository extends EntityRepository
|
||||
return $stats;
|
||||
}
|
||||
|
||||
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false)
|
||||
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false): void
|
||||
{
|
||||
$permissions = $this->getPermissionCriteria($qb, $user, $teams, $globalsOnly);
|
||||
if ($permissions->count() > 0) {
|
||||
$qb->andWhere($permissions);
|
||||
}
|
||||
}
|
||||
|
||||
private function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false): Andx
|
||||
{
|
||||
$andX = $qb->expr()->andX();
|
||||
|
||||
// make sure that all queries without a user see all projects
|
||||
if (null === $user && empty($teams)) {
|
||||
return;
|
||||
return $andX;
|
||||
}
|
||||
|
||||
// make sure that admins see all activities
|
||||
if (null !== $user && $user->canSeeAllData()) {
|
||||
return;
|
||||
return $andX;
|
||||
}
|
||||
|
||||
if (null !== $user) {
|
||||
@@ -177,33 +188,33 @@ class ActivityRepository extends EntityRepository
|
||||
}
|
||||
|
||||
if (empty($teams)) {
|
||||
$qb->andWhere('SIZE(a.teams) = 0');
|
||||
$andX->add('SIZE(a.teams) = 0');
|
||||
if (!$globalsOnly) {
|
||||
$qb->andWhere('SIZE(p.teams) = 0');
|
||||
$qb->andWhere('SIZE(c.teams) = 0');
|
||||
$andX->add('SIZE(p.teams) = 0');
|
||||
$andX->add('SIZE(c.teams) = 0');
|
||||
}
|
||||
|
||||
return;
|
||||
return $andX;
|
||||
}
|
||||
|
||||
$orActivity = $qb->expr()->orX(
|
||||
'SIZE(a.teams) = 0',
|
||||
$qb->expr()->isMemberOf(':teams', 'a.teams')
|
||||
);
|
||||
$qb->andWhere($orActivity);
|
||||
$andX->add($orActivity);
|
||||
|
||||
if (!$globalsOnly) {
|
||||
$orProject = $qb->expr()->orX(
|
||||
'SIZE(p.teams) = 0',
|
||||
$qb->expr()->isMemberOf(':teams', 'p.teams')
|
||||
);
|
||||
$qb->andWhere($orProject);
|
||||
$andX->add($orProject);
|
||||
|
||||
$orCustomer = $qb->expr()->orX(
|
||||
'SIZE(c.teams) = 0',
|
||||
$qb->expr()->isMemberOf(':teams', 'c.teams')
|
||||
);
|
||||
$qb->andWhere($orCustomer);
|
||||
$andX->add($orCustomer);
|
||||
}
|
||||
|
||||
$ids = array_values(array_unique(array_map(function (Team $team) {
|
||||
@@ -211,6 +222,8 @@ class ActivityRepository extends EntityRepository
|
||||
}, $teams)));
|
||||
|
||||
$qb->setParameter('teams', $ids);
|
||||
|
||||
return $andX;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,9 +255,9 @@ class ActivityRepository extends EntityRepository
|
||||
->addOrderBy('a.name', 'ASC')
|
||||
;
|
||||
|
||||
$where = $qb->expr()->andX();
|
||||
$mainQuery = $qb->expr()->andX();
|
||||
|
||||
$where->add($qb->expr()->eq('a.visible', ':visible'));
|
||||
$mainQuery->add($qb->expr()->eq('a.visible', ':visible'));
|
||||
$qb->setParameter('visible', true, \PDO::PARAM_BOOL);
|
||||
|
||||
if (!$query->isGlobalsOnly()) {
|
||||
@@ -254,7 +267,7 @@ class ActivityRepository extends EntityRepository
|
||||
->leftJoin('a.project', 'p')
|
||||
->leftJoin('p.customer', 'c');
|
||||
|
||||
$where->add(
|
||||
$mainQuery->add(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->isNull('a.project'),
|
||||
$qb->expr()->andX(
|
||||
@@ -268,9 +281,9 @@ class ActivityRepository extends EntityRepository
|
||||
}
|
||||
|
||||
if ($query->isGlobalsOnly()) {
|
||||
$where->add($qb->expr()->isNull('a.project'));
|
||||
$mainQuery->add($qb->expr()->isNull('a.project'));
|
||||
} elseif ($query->hasProjects()) {
|
||||
$where->add(
|
||||
$mainQuery->add(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->isNull('a.project'),
|
||||
$qb->expr()->in('a.project', ':project')
|
||||
@@ -279,28 +292,30 @@ class ActivityRepository extends EntityRepository
|
||||
$qb->setParameter('project', $query->getProjects());
|
||||
}
|
||||
|
||||
if (null !== $query->getActivityToIgnore()) {
|
||||
$qb->andWhere($qb->expr()->neq('a.id', ':ignored'));
|
||||
$qb->setParameter('ignored', $query->getActivityToIgnore());
|
||||
$permissions = $this->getPermissionCriteria($qb, $query->getUser(), $query->getTeams(), $query->isGlobalsOnly());
|
||||
if ($permissions->count() > 0) {
|
||||
$mainQuery->add($permissions);
|
||||
}
|
||||
|
||||
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams(), $query->isGlobalsOnly());
|
||||
$outerQuery = $qb->expr()->orX();
|
||||
|
||||
$or = $qb->expr()->orX();
|
||||
|
||||
// this must always be the last part before the or
|
||||
$or->add($where);
|
||||
|
||||
// this must always be the last part of the query
|
||||
if ($query->hasActivities()) {
|
||||
$or->add($qb->expr()->in('a.id', ':activity'));
|
||||
$outerQuery->add($qb->expr()->in('a.id', ':activity'));
|
||||
$qb->setParameter('activity', $query->getActivities());
|
||||
}
|
||||
|
||||
if ($or->count() > 0) {
|
||||
$qb->andWhere($or);
|
||||
if (null !== $query->getActivityToIgnore()) {
|
||||
$mainQuery = $qb->expr()->andX(
|
||||
$mainQuery,
|
||||
$qb->expr()->neq('a.id', ':ignored')
|
||||
);
|
||||
$qb->setParameter('ignored', $query->getActivityToIgnore());
|
||||
}
|
||||
|
||||
$outerQuery->add($mainQuery);
|
||||
|
||||
$qb->andWhere($outerQuery);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ use App\Repository\Query\CustomerQuery;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\ORMException;
|
||||
use Doctrine\ORM\Query;
|
||||
use Doctrine\ORM\Query\Expr\Andx;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Pagerfanta\Pagerfanta;
|
||||
|
||||
@@ -157,14 +158,24 @@ class CustomerRepository extends EntityRepository
|
||||
|
||||
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
|
||||
{
|
||||
$permissions = $this->getPermissionCriteria($qb, $user, $teams);
|
||||
if ($permissions->count() > 0) {
|
||||
$qb->andWhere($permissions);
|
||||
}
|
||||
}
|
||||
|
||||
private function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): Andx
|
||||
{
|
||||
$andX = $qb->expr()->andX();
|
||||
|
||||
// make sure that all queries without a user see all customers
|
||||
if (null === $user && empty($teams)) {
|
||||
return;
|
||||
return $andX;
|
||||
}
|
||||
|
||||
// make sure that admins see all customers
|
||||
if (null !== $user && $user->canSeeAllData()) {
|
||||
return;
|
||||
return $andX;
|
||||
}
|
||||
|
||||
if (null !== $user) {
|
||||
@@ -172,22 +183,24 @@ class CustomerRepository extends EntityRepository
|
||||
}
|
||||
|
||||
if (empty($teams)) {
|
||||
$qb->andWhere('SIZE(c.teams) = 0');
|
||||
$andX->add('SIZE(c.teams) = 0');
|
||||
|
||||
return;
|
||||
return $andX;
|
||||
}
|
||||
|
||||
$or = $qb->expr()->orX(
|
||||
'SIZE(c.teams) = 0',
|
||||
$qb->expr()->isMemberOf(':teams', 'c.teams')
|
||||
);
|
||||
$qb->andWhere($or);
|
||||
$andX->add($or);
|
||||
|
||||
$ids = array_values(array_unique(array_map(function (Team $team) {
|
||||
return $team->getId();
|
||||
}, $teams)));
|
||||
|
||||
$qb->setParameter('teams', $ids);
|
||||
|
||||
return $andX;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,21 +229,33 @@ class CustomerRepository extends EntityRepository
|
||||
->from(Customer::class, 'c')
|
||||
->orderBy('c.name', 'ASC');
|
||||
|
||||
// TODO this where and the next if($query->hasCustomers()) should go into their own $qb->expr()->orX()
|
||||
$qb->andWhere($qb->expr()->eq('c.visible', ':visible'));
|
||||
$mainQuery = $qb->expr()->andX();
|
||||
|
||||
$mainQuery->add($qb->expr()->eq('c.visible', ':visible'));
|
||||
$qb->setParameter('visible', true, \PDO::PARAM_BOOL);
|
||||
|
||||
$permissions = $this->getPermissionCriteria($qb, $query->getUser(), $query->getTeams());
|
||||
if ($permissions->count() > 0) {
|
||||
$mainQuery->add($permissions);
|
||||
}
|
||||
|
||||
$outerQuery = $qb->expr()->orX();
|
||||
|
||||
if ($query->hasCustomers()) {
|
||||
$qb->orWhere($qb->expr()->in('c.id', ':customer'))
|
||||
->setParameter('customer', $query->getCustomers());
|
||||
$outerQuery->add($qb->expr()->in('c.id', ':customer'));
|
||||
$qb->setParameter('customer', $query->getCustomers());
|
||||
}
|
||||
|
||||
if (null !== $query->getCustomerToIgnore()) {
|
||||
$qb->andWhere($qb->expr()->neq('c.id', ':ignored'));
|
||||
$mainQuery = $qb->expr()->andX(
|
||||
$mainQuery,
|
||||
$qb->expr()->neq('c.id', ':ignored')
|
||||
);
|
||||
$qb->setParameter('ignored', $query->getCustomerToIgnore());
|
||||
}
|
||||
|
||||
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams());
|
||||
$outerQuery->add($mainQuery);
|
||||
$qb->andWhere($outerQuery);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\ORMException;
|
||||
use Doctrine\ORM\Query;
|
||||
use Doctrine\ORM\Query\Expr\Andx;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Pagerfanta\Pagerfanta;
|
||||
|
||||
@@ -182,16 +183,26 @@ class ProjectRepository extends EntityRepository
|
||||
return $stats;
|
||||
}
|
||||
|
||||
public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
|
||||
public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
|
||||
{
|
||||
$permissions = $this->getPermissionCriteria($qb, $user, $teams);
|
||||
if ($permissions->count() > 0) {
|
||||
$qb->andWhere($permissions);
|
||||
}
|
||||
}
|
||||
|
||||
public function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): Andx
|
||||
{
|
||||
$andX = $qb->expr()->andX();
|
||||
|
||||
// make sure that all queries without a user see all projects
|
||||
if (null === $user && empty($teams)) {
|
||||
return;
|
||||
return $andX;
|
||||
}
|
||||
|
||||
// make sure that admins see all projects
|
||||
if (null !== $user && $user->canSeeAllData()) {
|
||||
return;
|
||||
return $andX;
|
||||
}
|
||||
|
||||
if (null !== $user) {
|
||||
@@ -199,29 +210,31 @@ class ProjectRepository extends EntityRepository
|
||||
}
|
||||
|
||||
if (empty($teams)) {
|
||||
$qb->andWhere('SIZE(c.teams) = 0');
|
||||
$qb->andWhere('SIZE(p.teams) = 0');
|
||||
$andX->add('SIZE(c.teams) = 0');
|
||||
$andX->add('SIZE(p.teams) = 0');
|
||||
|
||||
return;
|
||||
return $andX;
|
||||
}
|
||||
|
||||
$orProject = $qb->expr()->orX(
|
||||
'SIZE(p.teams) = 0',
|
||||
$qb->expr()->isMemberOf(':teams', 'p.teams')
|
||||
);
|
||||
$qb->andWhere($orProject);
|
||||
$andX->add($orProject);
|
||||
|
||||
$orCustomer = $qb->expr()->orX(
|
||||
'SIZE(c.teams) = 0',
|
||||
$qb->expr()->isMemberOf(':teams', 'c.teams')
|
||||
);
|
||||
$qb->andWhere($orCustomer);
|
||||
$andX->add($orCustomer);
|
||||
|
||||
$ids = array_values(array_unique(array_map(function (Team $team) {
|
||||
return $team->getId();
|
||||
}, $teams)));
|
||||
|
||||
$qb->setParameter('teams', $ids);
|
||||
|
||||
return $andX;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,57 +272,46 @@ class ProjectRepository extends EntityRepository
|
||||
$qb->addSelect('c');
|
||||
}
|
||||
|
||||
$qb->andWhere($qb->expr()->eq('p.visible', ':visible'));
|
||||
$qb->andWhere($qb->expr()->eq('c.visible', ':customer_visible'));
|
||||
|
||||
if (!$query->isIgnoreDate()) {
|
||||
$now = new DateTime();
|
||||
$qb->andWhere(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->lte('p.start', ':start'),
|
||||
$qb->expr()->isNull('p.start')
|
||||
),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->gte('p.end', ':start'),
|
||||
$qb->expr()->isNull('p.end')
|
||||
)
|
||||
)
|
||||
)->setParameter('start', $now);
|
||||
|
||||
$qb->andWhere(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->gte('p.end', ':end'),
|
||||
$qb->expr()->isNull('p.end')
|
||||
),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->lte('p.start', ':end'),
|
||||
$qb->expr()->isNull('p.start')
|
||||
)
|
||||
)
|
||||
)->setParameter('end', $now);
|
||||
}
|
||||
$mainQuery = $qb->expr()->andX();
|
||||
|
||||
$mainQuery->add($qb->expr()->eq('p.visible', ':visible'));
|
||||
$qb->setParameter('visible', true, \PDO::PARAM_BOOL);
|
||||
|
||||
$mainQuery->add($qb->expr()->eq('c.visible', ':customer_visible'));
|
||||
$qb->setParameter('customer_visible', true, \PDO::PARAM_BOOL);
|
||||
|
||||
if ($query->hasProjects()) {
|
||||
$qb->orWhere($qb->expr()->in('p.id', ':project'))
|
||||
->setParameter('project', $query->getProjects());
|
||||
if (!$query->isIgnoreDate()) {
|
||||
$andx = $this->addProjectStartAndEndDate($qb, $query->getProjectStart(), $query->getProjectEnd());
|
||||
$mainQuery->add($andx);
|
||||
}
|
||||
|
||||
if ($query->hasCustomers()) {
|
||||
$qb->andWhere($qb->expr()->in('p.customer', ':customer'))
|
||||
->setParameter('customer', $query->getCustomers());
|
||||
$mainQuery->add($qb->expr()->in('p.customer', ':customer'));
|
||||
$qb->setParameter('customer', $query->getCustomers());
|
||||
}
|
||||
|
||||
$permissions = $this->getPermissionCriteria($qb, $query->getUser(), $query->getTeams());
|
||||
if ($permissions->count() > 0) {
|
||||
$mainQuery->add($permissions);
|
||||
}
|
||||
|
||||
$outerQuery = $qb->expr()->orX();
|
||||
|
||||
if ($query->hasProjects()) {
|
||||
$outerQuery->add($qb->expr()->in('p.id', ':project'));
|
||||
$qb->setParameter('project', $query->getProjects());
|
||||
}
|
||||
|
||||
if (null !== $query->getProjectToIgnore()) {
|
||||
$qb->andWhere($qb->expr()->neq('p.id', ':ignored'));
|
||||
$mainQuery = $qb->expr()->andX(
|
||||
$mainQuery,
|
||||
$qb->expr()->neq('p.id', ':ignored')
|
||||
);
|
||||
$qb->setParameter('ignored', $query->getProjectToIgnore());
|
||||
}
|
||||
|
||||
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams());
|
||||
$outerQuery->add($mainQuery);
|
||||
$qb->andWhere($outerQuery);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
@@ -369,39 +371,8 @@ class ProjectRepository extends EntityRepository
|
||||
// begin < to and end = null
|
||||
// begin > from and end < to
|
||||
// ... and more ...
|
||||
|
||||
$begin = $query->getProjectStart();
|
||||
$end = $query->getProjectEnd();
|
||||
|
||||
if (null !== $begin) {
|
||||
$qb->andWhere(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->lte('p.start', ':start'),
|
||||
$qb->expr()->isNull('p.start')
|
||||
),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->gte('p.end', ':start'),
|
||||
$qb->expr()->isNull('p.end')
|
||||
)
|
||||
)
|
||||
)->setParameter('start', $query->getProjectStart());
|
||||
}
|
||||
|
||||
if (null !== $end) {
|
||||
$qb->andWhere(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->gte('p.end', ':end'),
|
||||
$qb->expr()->isNull('p.end')
|
||||
),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->lte('p.start', ':end'),
|
||||
$qb->expr()->isNull('p.start')
|
||||
)
|
||||
)
|
||||
)->setParameter('end', $query->getProjectEnd());
|
||||
}
|
||||
$times = $this->addProjectStartAndEndDate($qb, $query->getProjectStart(), $query->getProjectEnd());
|
||||
$qb->andWhere($times);
|
||||
|
||||
$this->addPermissionCriteria($qb, $query->getCurrentUser());
|
||||
|
||||
@@ -440,6 +411,45 @@ class ProjectRepository extends EntityRepository
|
||||
return $qb;
|
||||
}
|
||||
|
||||
private function addProjectStartAndEndDate(QueryBuilder $qb, ?DateTime $begin, ?DateTime $end): Andx
|
||||
{
|
||||
$and = $qb->expr()->andX();
|
||||
|
||||
if (null !== $begin) {
|
||||
$and->add(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->lte('p.start', ':start'),
|
||||
$qb->expr()->isNull('p.start')
|
||||
),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->gte('p.end', ':start'),
|
||||
$qb->expr()->isNull('p.end')
|
||||
)
|
||||
)
|
||||
);
|
||||
$qb->setParameter('start', $begin);
|
||||
}
|
||||
|
||||
if (null !== $end) {
|
||||
$and->add(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->gte('p.end', ':end'),
|
||||
$qb->expr()->isNull('p.end')
|
||||
),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->lte('p.start', ':end'),
|
||||
$qb->expr()->isNull('p.start')
|
||||
)
|
||||
)
|
||||
);
|
||||
$qb->setParameter('end', $end);
|
||||
}
|
||||
|
||||
return $and;
|
||||
}
|
||||
|
||||
public function countProjectsForQuery(ProjectQuery $query): int
|
||||
{
|
||||
$qb = $this->getQueryBuilderForQuery($query);
|
||||
|
||||
@@ -14,6 +14,14 @@ use App\Entity\Project;
|
||||
|
||||
final class ProjectFormTypeQuery extends BaseFormTypeQuery
|
||||
{
|
||||
/**
|
||||
* @var \DateTime|null
|
||||
*/
|
||||
private $projectStart;
|
||||
/**
|
||||
* @var \DateTime|null
|
||||
*/
|
||||
private $projectEnd;
|
||||
/**
|
||||
* @var Project|null
|
||||
*/
|
||||
@@ -22,8 +30,8 @@ final class ProjectFormTypeQuery extends BaseFormTypeQuery
|
||||
private $withCustomer = false;
|
||||
|
||||
/**
|
||||
* @param Project|int|null $project
|
||||
* @param Customer|int|null $customer
|
||||
* @param Project|int|null|array<int>|array<Project> $project
|
||||
* @param Customer|int|null|array<int>|array<Customer> $customer
|
||||
*/
|
||||
public function __construct($project = null, $customer = null)
|
||||
{
|
||||
@@ -40,13 +48,25 @@ final class ProjectFormTypeQuery extends BaseFormTypeQuery
|
||||
}
|
||||
$this->setCustomers($customer);
|
||||
}
|
||||
|
||||
$this->projectStart = $this->projectEnd = new \DateTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether customers should be joined
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function withCustomer(): bool
|
||||
{
|
||||
return $this->withCustomer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Directly join the customer
|
||||
*
|
||||
* @param bool $withCustomer
|
||||
*/
|
||||
public function setWithCustomer(bool $withCustomer): void
|
||||
{
|
||||
$this->withCustomer = $withCustomer;
|
||||
@@ -60,11 +80,9 @@ final class ProjectFormTypeQuery extends BaseFormTypeQuery
|
||||
return $this->projectToIgnore;
|
||||
}
|
||||
|
||||
public function setProjectToIgnore(Project $projectToIgnore): ProjectFormTypeQuery
|
||||
public function setProjectToIgnore(Project $projectToIgnore): void
|
||||
{
|
||||
$this->projectToIgnore = $projectToIgnore;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isIgnoreDate(): bool
|
||||
@@ -72,10 +90,28 @@ final class ProjectFormTypeQuery extends BaseFormTypeQuery
|
||||
return $this->ignoreDate;
|
||||
}
|
||||
|
||||
public function setIgnoreDate(bool $ignoreDate): ProjectFormTypeQuery
|
||||
public function setIgnoreDate(bool $ignoreDate): void
|
||||
{
|
||||
$this->ignoreDate = $ignoreDate;
|
||||
}
|
||||
|
||||
return $this;
|
||||
public function getProjectStart(): ?\DateTime
|
||||
{
|
||||
return $this->projectStart;
|
||||
}
|
||||
|
||||
public function setProjectStart(?\DateTime $projectStart): void
|
||||
{
|
||||
$this->projectStart = $projectStart;
|
||||
}
|
||||
|
||||
public function getProjectEnd(): ?\DateTime
|
||||
{
|
||||
return $this->projectEnd;
|
||||
}
|
||||
|
||||
public function setProjectEnd(?\DateTime $projectEnd): void
|
||||
{
|
||||
$this->projectEnd = $projectEnd;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1052,7 +1052,7 @@ class TimesheetRepository extends EntityRepository
|
||||
* @param User|null $user
|
||||
* @param DateTime|null $startFrom
|
||||
* @param int $limit
|
||||
* @return array|mixed
|
||||
* @return Timesheet[]
|
||||
* @throws \Doctrine\ORM\Query\QueryException
|
||||
*/
|
||||
public function getRecentActivities(User $user = null, DateTime $startFrom = null, int $limit = 10)
|
||||
|
||||
@@ -99,6 +99,7 @@ final class IconExtension extends AbstractExtension
|
||||
'users' => 'fas fa-user-friends',
|
||||
'visibility' => 'far fa-eye',
|
||||
'warning' => 'fas fa-exclamation-triangle',
|
||||
'weekly-times' => 'fas fa-th',
|
||||
'xlsx' => 'fas fa-file-excel',
|
||||
];
|
||||
|
||||
|
||||
@@ -80,6 +80,14 @@ final class LocaleFormatExtensions extends AbstractExtension
|
||||
|
||||
return ($day === 0 || $day === 6);
|
||||
}),
|
||||
new TwigTest('today', function ($dateTime) {
|
||||
if (!$dateTime instanceof \DateTime) {
|
||||
return false;
|
||||
}
|
||||
$compare = new \DateTime('now', $dateTime->getTimezone());
|
||||
|
||||
return $compare->format('Y-m-d') === $dateTime->format('Y-m-d');
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class ProjectValidator extends ConstraintValidator
|
||||
public function validate($value, Constraint $constraint)
|
||||
{
|
||||
if (!($constraint instanceof ProjectConstraint)) {
|
||||
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\Project');
|
||||
throw new UnexpectedTypeException($constraint, ProjectConstraint::class);
|
||||
}
|
||||
|
||||
if (!\is_object($value) || !($value instanceof Project)) {
|
||||
|
||||
25
src/Validator/Constraints/QuickEntryModel.php
Normal file
25
src/Validator/Constraints/QuickEntryModel.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
/**
|
||||
* @Annotation
|
||||
* @Target({"CLASS"})
|
||||
*/
|
||||
class QuickEntryModel extends Constraint
|
||||
{
|
||||
public const ACTIVITY_REQUIRED = 'quick-entry-model-01';
|
||||
public const PROJECT_REQUIRED = 'quick-entry-model-02';
|
||||
|
||||
public $messageActivityRequired = 'An activity needs to be selected.';
|
||||
public $messageProjectRequired = 'A project needs to be selected.';
|
||||
}
|
||||
56
src/Validator/Constraints/QuickEntryModelValidator.php
Normal file
56
src/Validator/Constraints/QuickEntryModelValidator.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?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\Validator\Constraints;
|
||||
|
||||
use App\Model\QuickEntryModel;
|
||||
use App\Validator\Constraints\QuickEntryModel as QuickEntryModelConstraint;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
class QuickEntryModelValidator extends ConstraintValidator
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function validate($value, Constraint $constraint)
|
||||
{
|
||||
if (!$constraint instanceof QuickEntryModelConstraint) {
|
||||
throw new UnexpectedTypeException($constraint, QuickEntryModelConstraint::class);
|
||||
}
|
||||
|
||||
if (!\is_object($value) || !($value instanceof QuickEntryModel)) {
|
||||
throw new UnexpectedTypeException($value, QuickEntryModel::class);
|
||||
}
|
||||
|
||||
/** @var QuickEntryModel $model */
|
||||
$model = $value;
|
||||
|
||||
if ($model->isPrototype()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($model->hasExistingTimesheet() || $model->hasNewTimesheet()) {
|
||||
if ($model->getActivity() === null) {
|
||||
$this->context->buildViolation($constraint->messageActivityRequired)
|
||||
->atPath('activity')
|
||||
->setCode(QuickEntryModelConstraint::ACTIVITY_REQUIRED)
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if ($model->getProject() === null) {
|
||||
$this->context->buildViolation($constraint->messageProjectRequired)
|
||||
->atPath('project')
|
||||
->setCode(QuickEntryModelConstraint::PROJECT_REQUIRED)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/Validator/Constraints/QuickEntryTimesheet.php
Normal file
20
src/Validator/Constraints/QuickEntryTimesheet.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?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\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
/**
|
||||
* @Annotation
|
||||
* @Target({"CLASS"})
|
||||
*/
|
||||
class QuickEntryTimesheet extends Constraint
|
||||
{
|
||||
}
|
||||
61
src/Validator/Constraints/QuickEntryTimesheetValidator.php
Normal file
61
src/Validator/Constraints/QuickEntryTimesheetValidator.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?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\Validator\Constraints;
|
||||
|
||||
use App\Entity\Timesheet as TimesheetEntity;
|
||||
use App\Validator\Constraints\QuickEntryTimesheet as QuickEntryTimesheetConstraint;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
class QuickEntryTimesheetValidator extends ConstraintValidator
|
||||
{
|
||||
/**
|
||||
* @var Constraint[]
|
||||
*/
|
||||
private $constraints;
|
||||
|
||||
/**
|
||||
* @param Constraint[] $constraints
|
||||
*/
|
||||
public function __construct(iterable $constraints)
|
||||
{
|
||||
$this->constraints = $constraints;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function validate($value, Constraint $constraint)
|
||||
{
|
||||
if (!$constraint instanceof QuickEntryTimesheetConstraint) {
|
||||
throw new UnexpectedTypeException($constraint, QuickEntryTimesheetConstraint::class);
|
||||
}
|
||||
|
||||
if (!\is_object($value) || !($value instanceof TimesheetEntity)) {
|
||||
throw new UnexpectedTypeException($value, TimesheetEntity::class);
|
||||
}
|
||||
|
||||
/** @var TimesheetEntity $timesheet */
|
||||
$timesheet = $value;
|
||||
|
||||
if ($timesheet->getId() === null && $timesheet->getDuration(false) === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->constraints as $constraint) {
|
||||
$this->context
|
||||
->getValidator()
|
||||
->inContext($this->context)
|
||||
->atPath('duration')
|
||||
->validate($timesheet, $constraint, [Constraint::DEFAULT_GROUP]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,22 +18,32 @@ use Symfony\Component\Validator\Constraint;
|
||||
*/
|
||||
class Timesheet extends Constraint
|
||||
{
|
||||
public const MISSING_BEGIN_ERROR = 'kimai-timesheet-81';
|
||||
public const END_BEFORE_BEGIN_ERROR = 'kimai-timesheet-82';
|
||||
public const MISSING_ACTIVITY_ERROR = 'kimai-timesheet-84';
|
||||
public const MISSING_PROJECT_ERROR = 'kimai-timesheet-85';
|
||||
public const ACTIVITY_PROJECT_MISMATCH_ERROR = 'kimai-timesheet-86';
|
||||
public const DISABLED_ACTIVITY_ERROR = 'kimai-timesheet-87';
|
||||
public const DISABLED_PROJECT_ERROR = 'kimai-timesheet-88';
|
||||
public const DISABLED_CUSTOMER_ERROR = 'kimai-timesheet-89';
|
||||
public const PROJECT_NOT_STARTED = 'kimai-timesheet-91';
|
||||
public const PROJECT_ALREADY_ENDED = 'kimai-timesheet-92';
|
||||
/** @deprecated since 1.15.3 - use TimesheetBasic::MISSING_BEGIN_ERROR instead */
|
||||
public const MISSING_BEGIN_ERROR = TimesheetBasic::MISSING_BEGIN_ERROR;
|
||||
/** @deprecated since 1.15.3 - use TimesheetBasic::END_BEFORE_BEGIN_ERROR instead */
|
||||
public const END_BEFORE_BEGIN_ERROR = TimesheetBasic::END_BEFORE_BEGIN_ERROR;
|
||||
/** @deprecated since 1.15.3 - use TimesheetBasic::MISSING_ACTIVITY_ERROR instead */
|
||||
public const MISSING_ACTIVITY_ERROR = TimesheetBasic::MISSING_ACTIVITY_ERROR;
|
||||
/** @deprecated since 1.15.3 - use TimesheetBasic::MISSING_PROJECT_ERROR instead */
|
||||
public const MISSING_PROJECT_ERROR = TimesheetBasic::MISSING_PROJECT_ERROR;
|
||||
/** @deprecated since 1.15.3 - use TimesheetBasic::ACTIVITY_PROJECT_MISMATCH_ERROR instead */
|
||||
public const ACTIVITY_PROJECT_MISMATCH_ERROR = TimesheetBasic::ACTIVITY_PROJECT_MISMATCH_ERROR;
|
||||
/** @deprecated since 1.15.3 - use TimesheetBasic::DISABLED_ACTIVITY_ERROR instead */
|
||||
public const DISABLED_ACTIVITY_ERROR = TimesheetBasic::DISABLED_ACTIVITY_ERROR;
|
||||
/** @deprecated since 1.15.3 - use TimesheetBasic::DISABLED_PROJECT_ERROR instead */
|
||||
public const DISABLED_PROJECT_ERROR = TimesheetBasic::DISABLED_PROJECT_ERROR;
|
||||
/** @deprecated since 1.15.3 - use TimesheetBasic::DISABLED_CUSTOMER_ERROR instead */
|
||||
public const DISABLED_CUSTOMER_ERROR = TimesheetBasic::DISABLED_CUSTOMER_ERROR;
|
||||
/** @deprecated since 1.15.3 - use TimesheetBasic::PROJECT_NOT_STARTED instead */
|
||||
public const PROJECT_NOT_STARTED = TimesheetBasic::PROJECT_NOT_STARTED;
|
||||
/** @deprecated since 1.15.3 - use TimesheetBasic::PROJECT_ALREADY_ENDED instead */
|
||||
public const PROJECT_ALREADY_ENDED = TimesheetBasic::PROJECT_ALREADY_ENDED;
|
||||
|
||||
protected static $errorNames = [
|
||||
self::MISSING_BEGIN_ERROR => 'You must submit a begin date.',
|
||||
self::END_BEFORE_BEGIN_ERROR => 'End date must not be earlier then start date.',
|
||||
self::MISSING_ACTIVITY_ERROR => 'A timesheet must have an activity.',
|
||||
self::MISSING_PROJECT_ERROR => 'A timesheet must have a project.',
|
||||
self::MISSING_ACTIVITY_ERROR => 'An activity needs to be selected.',
|
||||
self::MISSING_PROJECT_ERROR => 'A project needs to be selected.',
|
||||
self::ACTIVITY_PROJECT_MISMATCH_ERROR => 'Project mismatch, project specific activity and timesheet project are different.',
|
||||
self::DISABLED_ACTIVITY_ERROR => 'Cannot start a disabled activity.',
|
||||
self::DISABLED_PROJECT_ERROR => 'Cannot start a disabled project.',
|
||||
|
||||
50
src/Validator/Constraints/TimesheetBasic.php
Normal file
50
src/Validator/Constraints/TimesheetBasic.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?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\Validator\Constraints;
|
||||
|
||||
use Doctrine\Common\Annotations\Annotation\Target;
|
||||
|
||||
/**
|
||||
* @Annotation
|
||||
* @Target({"CLASS", "PROPERTY", "METHOD", "ANNOTATION"})
|
||||
*/
|
||||
class TimesheetBasic extends TimesheetConstraint
|
||||
{
|
||||
public const MISSING_BEGIN_ERROR = 'kimai-timesheet-81';
|
||||
public const END_BEFORE_BEGIN_ERROR = 'kimai-timesheet-82';
|
||||
public const MISSING_ACTIVITY_ERROR = 'kimai-timesheet-84';
|
||||
public const MISSING_PROJECT_ERROR = 'kimai-timesheet-85';
|
||||
public const ACTIVITY_PROJECT_MISMATCH_ERROR = 'kimai-timesheet-86';
|
||||
public const DISABLED_ACTIVITY_ERROR = 'kimai-timesheet-87';
|
||||
public const DISABLED_PROJECT_ERROR = 'kimai-timesheet-88';
|
||||
public const DISABLED_CUSTOMER_ERROR = 'kimai-timesheet-89';
|
||||
public const PROJECT_NOT_STARTED = 'kimai-timesheet-91';
|
||||
public const PROJECT_ALREADY_ENDED = 'kimai-timesheet-92';
|
||||
|
||||
protected static $errorNames = [
|
||||
self::MISSING_BEGIN_ERROR => 'You must submit a begin date.',
|
||||
self::END_BEFORE_BEGIN_ERROR => 'End date must not be earlier then start date.',
|
||||
self::MISSING_ACTIVITY_ERROR => 'An activity needs to be selected.',
|
||||
self::MISSING_PROJECT_ERROR => 'A project needs to be selected.',
|
||||
self::ACTIVITY_PROJECT_MISMATCH_ERROR => 'Project mismatch, project specific activity and timesheet project are different.',
|
||||
self::DISABLED_ACTIVITY_ERROR => 'Cannot start a disabled activity.',
|
||||
self::DISABLED_PROJECT_ERROR => 'Cannot start a disabled project.',
|
||||
self::DISABLED_CUSTOMER_ERROR => 'Cannot start a disabled customer.',
|
||||
self::PROJECT_NOT_STARTED => 'The project has not started at that time.',
|
||||
self::PROJECT_ALREADY_ENDED => 'The project is finished at that time.',
|
||||
];
|
||||
|
||||
public $message = 'This timesheet has invalid settings.';
|
||||
|
||||
public function getTargets()
|
||||
{
|
||||
return self::CLASS_CONSTRAINT;
|
||||
}
|
||||
}
|
||||
172
src/Validator/Constraints/TimesheetBasicValidator.php
Normal file
172
src/Validator/Constraints/TimesheetBasicValidator.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?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\Validator\Constraints;
|
||||
|
||||
use App\Entity\Timesheet as TimesheetEntity;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
final class TimesheetBasicValidator extends ConstraintValidator
|
||||
{
|
||||
/**
|
||||
* @param TimesheetEntity $timesheet
|
||||
* @param Constraint $constraint
|
||||
*/
|
||||
public function validate($timesheet, Constraint $constraint)
|
||||
{
|
||||
if (!($constraint instanceof TimesheetBasic)) {
|
||||
throw new UnexpectedTypeException($constraint, TimesheetBasic::class);
|
||||
}
|
||||
|
||||
if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {
|
||||
throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
|
||||
}
|
||||
|
||||
$this->validateBeginAndEnd($timesheet, $this->context);
|
||||
$this->validateActivityAndProject($timesheet, $this->context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param TimesheetEntity $timesheet
|
||||
* @param ExecutionContextInterface $context
|
||||
*/
|
||||
protected function validateBeginAndEnd(TimesheetEntity $timesheet, ExecutionContextInterface $context)
|
||||
{
|
||||
$begin = $timesheet->getBegin();
|
||||
$end = $timesheet->getEnd();
|
||||
|
||||
if (null === $begin) {
|
||||
$context->buildViolation('You must submit a begin date.')
|
||||
->atPath('begin')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetBasic::MISSING_BEGIN_ERROR)
|
||||
->addViolation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== $end && $begin > $end) {
|
||||
$context->buildViolation('End date must not be earlier then start date.')
|
||||
->atPath('end')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetBasic::END_BEFORE_BEGIN_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param TimesheetEntity $timesheet
|
||||
* @param ExecutionContextInterface $context
|
||||
*/
|
||||
protected function validateActivityAndProject(TimesheetEntity $timesheet, ExecutionContextInterface $context)
|
||||
{
|
||||
if (null === ($activity = $timesheet->getActivity())) {
|
||||
$context->buildViolation('An activity needs to be selected.')
|
||||
->atPath('activity')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetBasic::MISSING_ACTIVITY_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === ($project = $timesheet->getProject())) {
|
||||
$context->buildViolation('A project needs to be selected.')
|
||||
->atPath('project')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetBasic::MISSING_PROJECT_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $activity || null === $project) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== $activity->getProject() && $activity->getProject() !== $project) {
|
||||
$context->buildViolation('Project mismatch, project specific activity and timesheet project are different.')
|
||||
->atPath('project')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetBasic::ACTIVITY_PROJECT_MISMATCH_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
$timesheetEnd = $timesheet->getEnd();
|
||||
$newOrStarted = null === $timesheetEnd || $timesheet->getId() === null;
|
||||
|
||||
if ($newOrStarted && !$activity->isVisible()) {
|
||||
$context->buildViolation('Cannot start a disabled activity.')
|
||||
->atPath('activity')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetBasic::DISABLED_ACTIVITY_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if ($newOrStarted && !$project->isVisible()) {
|
||||
$context->buildViolation('Cannot start a disabled project.')
|
||||
->atPath('project')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetBasic::DISABLED_PROJECT_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if ($newOrStarted && !$project->getCustomer()->isVisible()) {
|
||||
$context->buildViolation('Cannot start a disabled customer.')
|
||||
->atPath('customer')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetBasic::DISABLED_CUSTOMER_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
$pathStart = 'begin';
|
||||
$pathEnd = 'end';
|
||||
|
||||
$projectBegin = $project->getStart();
|
||||
$projectEnd = $project->getEnd();
|
||||
|
||||
if (null === $projectBegin && null === $projectEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
$timesheetStart = $timesheet->getBegin();
|
||||
$timesheetEnd = $timesheet->getEnd();
|
||||
|
||||
if (null !== $timesheetStart && $pathStart !== null) {
|
||||
if (null !== $projectBegin && $timesheetStart->getTimestamp() < $projectBegin->getTimestamp()) {
|
||||
$context->buildViolation('The project has not started at that time.')
|
||||
->atPath($pathStart)
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetBasic::PROJECT_NOT_STARTED)
|
||||
->addViolation();
|
||||
} elseif (null !== $projectEnd && $timesheetStart->getTimestamp() > $projectEnd->getTimestamp()) {
|
||||
$context->buildViolation('The project is finished at that time.')
|
||||
->atPath($pathStart)
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetBasic::PROJECT_ALREADY_ENDED)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $timesheetEnd && $pathEnd !== null) {
|
||||
if (null !== $projectEnd && $timesheetEnd->getTimestamp() > $projectEnd->getTimestamp()) {
|
||||
$context->buildViolation('The project is finished at that time.')
|
||||
->atPath($pathEnd)
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetBasic::PROJECT_ALREADY_ENDED)
|
||||
->addViolation();
|
||||
} elseif (null !== $projectBegin && $timesheetEnd->getTimestamp() < $projectBegin->getTimestamp()) {
|
||||
$context->buildViolation('The project has not started at that time.')
|
||||
->atPath($pathEnd)
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetBasic::PROJECT_NOT_STARTED)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,11 @@ final class TimesheetBudgetUsedValidator extends ConstraintValidator
|
||||
$duration = $timesheet->getEnd()->getTimestamp() - $timesheet->getBegin()->getTimestamp();
|
||||
}
|
||||
|
||||
// this validator needs a project to calculate the rates
|
||||
if ($timesheet->getProject() === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$timeRate = $this->rateService->calculate($timesheet);
|
||||
$rate = $timeRate->getRate();
|
||||
|
||||
|
||||
30
src/Validator/Constraints/TimesheetExported.php
Normal file
30
src/Validator/Constraints/TimesheetExported.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?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\Validator\Constraints;
|
||||
|
||||
final class TimesheetExported extends TimesheetConstraint
|
||||
{
|
||||
public const TIMESHEET_EXPORTED = 'kimai-timesheet-exported-01';
|
||||
|
||||
protected static $errorNames = [
|
||||
self::TIMESHEET_EXPORTED => 'This timesheet is already exported.',
|
||||
];
|
||||
|
||||
public $message = 'This timesheet is already exported.';
|
||||
/**
|
||||
* @var \DateTime|string|null
|
||||
*/
|
||||
public $now;
|
||||
|
||||
public function getTargets()
|
||||
{
|
||||
return self::CLASS_CONSTRAINT;
|
||||
}
|
||||
}
|
||||
55
src/Validator/Constraints/TimesheetExportedValidator.php
Normal file
55
src/Validator/Constraints/TimesheetExportedValidator.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?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\Validator\Constraints;
|
||||
|
||||
use App\Entity\Timesheet as TimesheetEntity;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
final class TimesheetExportedValidator extends ConstraintValidator
|
||||
{
|
||||
private $security;
|
||||
|
||||
public function __construct(Security $security)
|
||||
{
|
||||
$this->security = $security;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param TimesheetEntity $timesheet
|
||||
* @param Constraint $constraint
|
||||
*/
|
||||
public function validate($timesheet, Constraint $constraint)
|
||||
{
|
||||
if (!($constraint instanceof TimesheetExported)) {
|
||||
throw new UnexpectedTypeException($constraint, TimesheetExported::class);
|
||||
}
|
||||
|
||||
if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {
|
||||
throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
|
||||
}
|
||||
|
||||
if (!$timesheet->isExported()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== $this->security->getUser() && $this->security->isGranted('edit_exported_timesheet')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->context->buildViolation(TimesheetExported::getErrorName(TimesheetExported::TIMESHEET_EXPORTED))
|
||||
->atPath('exported')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetExported::TIMESHEET_EXPORTED)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,15 @@ namespace App\Validator\Constraints;
|
||||
final class TimesheetLongRunning extends TimesheetConstraint
|
||||
{
|
||||
public const LONG_RUNNING = 'kimai-timesheet-long-running-01';
|
||||
public const MAXIMUM = 'kimai-timesheet-long-running-02';
|
||||
|
||||
protected static $errorNames = [
|
||||
self::LONG_RUNNING => 'TIMESHEET_LONG_RUNNING',
|
||||
self::MAXIMUM => 'MAXIMUM',
|
||||
];
|
||||
|
||||
public $message = 'Maximum duration of {{ value }} hours exceeded.';
|
||||
public $maximumMessage = 'Maximum duration exceeded.';
|
||||
|
||||
public function getTargets()
|
||||
{
|
||||
|
||||
@@ -42,6 +42,18 @@ final class TimesheetLongRunningValidator extends ConstraintValidator
|
||||
return;
|
||||
}
|
||||
|
||||
// one year is currently the maximum that can be logged (which is already not logically)
|
||||
// the database column could hold more data, but let's limit it here
|
||||
if ($timesheet->getDuration() > 31536000) {
|
||||
$this->context->buildViolation($constraint->maximumMessage)
|
||||
->setTranslationDomain('validators')
|
||||
->atPath('duration')
|
||||
->setCode(TimesheetLongRunning::MAXIMUM)
|
||||
->addViolation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$maxMinutes = $this->systemConfiguration->getTimesheetLongRunningDuration();
|
||||
|
||||
if ($maxMinutes <= 0) {
|
||||
|
||||
@@ -28,7 +28,7 @@ class TimesheetMultiUpdate extends Constraint
|
||||
|
||||
protected static $errorNames = [
|
||||
self::MISSING_ACTIVITY_ERROR => 'You need to choose an activity, if the project should be changed.',
|
||||
self::MISSING_PROJECT_ERROR => 'A timesheet must have a project.',
|
||||
self::MISSING_PROJECT_ERROR => 'A project needs to be selected.',
|
||||
self::ACTIVITY_PROJECT_MISMATCH_ERROR => 'Project mismatch: chosen project does not match the activity project.',
|
||||
self::DISABLED_ACTIVITY_ERROR => 'Cannot start a disabled activity.',
|
||||
self::DISABLED_PROJECT_ERROR => 'Cannot start a disabled project.',
|
||||
|
||||
@@ -13,7 +13,6 @@ use App\Entity\Timesheet as TimesheetEntity;
|
||||
use App\Validator\Constraints\Timesheet as TimesheetConstraint;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
final class TimesheetValidator extends ConstraintValidator
|
||||
@@ -45,10 +44,6 @@ final class TimesheetValidator extends ConstraintValidator
|
||||
throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
|
||||
}
|
||||
|
||||
$this->validateBeginAndEnd($timesheet, $this->context);
|
||||
$this->validateActivityAndProject($timesheet, $this->context);
|
||||
$this->validateActiveLimit($timesheet, $this->context);
|
||||
|
||||
foreach ($this->constraints as $constraint) {
|
||||
$this->context
|
||||
->getValidator()
|
||||
@@ -56,142 +51,4 @@ final class TimesheetValidator extends ConstraintValidator
|
||||
->validate($timesheet, $constraint, [Constraint::DEFAULT_GROUP]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param TimesheetEntity $timesheet
|
||||
* @param ExecutionContextInterface $context
|
||||
*/
|
||||
protected function validateActiveLimit(TimesheetEntity $timesheet, ExecutionContextInterface $context)
|
||||
{
|
||||
// TODO check active entries against hard_limit
|
||||
}
|
||||
|
||||
/**
|
||||
* @param TimesheetEntity $timesheet
|
||||
* @param ExecutionContextInterface $context
|
||||
*/
|
||||
protected function validateBeginAndEnd(TimesheetEntity $timesheet, ExecutionContextInterface $context)
|
||||
{
|
||||
$begin = $timesheet->getBegin();
|
||||
$end = $timesheet->getEnd();
|
||||
|
||||
if (null === $begin) {
|
||||
$context->buildViolation('You must submit a begin date.')
|
||||
->atPath('begin')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::MISSING_BEGIN_ERROR)
|
||||
->addViolation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== $end && $begin > $end) {
|
||||
$context->buildViolation('End date must not be earlier then start date.')
|
||||
->atPath('end')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::END_BEFORE_BEGIN_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param TimesheetEntity $timesheet
|
||||
* @param ExecutionContextInterface $context
|
||||
*/
|
||||
protected function validateActivityAndProject(TimesheetEntity $timesheet, ExecutionContextInterface $context)
|
||||
{
|
||||
if (null === ($activity = $timesheet->getActivity())) {
|
||||
$context->buildViolation('A timesheet must have an activity.')
|
||||
->atPath('activity')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::MISSING_ACTIVITY_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === ($project = $timesheet->getProject())) {
|
||||
$context->buildViolation('A timesheet must have a project.')
|
||||
->atPath('project')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::MISSING_PROJECT_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $activity || null === $project) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== $activity->getProject() && $activity->getProject() !== $project) {
|
||||
$context->buildViolation('Project mismatch, project specific activity and timesheet project are different.')
|
||||
->atPath('project')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::ACTIVITY_PROJECT_MISMATCH_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
$timesheetEnd = $timesheet->getEnd();
|
||||
|
||||
if (null === $timesheetEnd && !$activity->isVisible()) {
|
||||
$context->buildViolation('Cannot start a disabled activity.')
|
||||
->atPath('activity')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::DISABLED_ACTIVITY_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $timesheetEnd && !$project->isVisible()) {
|
||||
$context->buildViolation('Cannot start a disabled project.')
|
||||
->atPath('project')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::DISABLED_PROJECT_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $timesheetEnd && !$project->getCustomer()->isVisible()) {
|
||||
$context->buildViolation('Cannot start a disabled customer.')
|
||||
->atPath('customer')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::DISABLED_CUSTOMER_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
$projectBegin = $project->getStart();
|
||||
$projectEnd = $project->getEnd();
|
||||
|
||||
if (null !== $projectBegin || null !== $projectEnd) {
|
||||
$timesheetStart = $timesheet->getBegin();
|
||||
$timesheetEnd = $timesheet->getEnd();
|
||||
|
||||
if (null !== $timesheetStart) {
|
||||
if (null !== $projectBegin && $timesheetStart->getTimestamp() < $projectBegin->getTimestamp()) {
|
||||
$context->buildViolation('The project has not started at that time.')
|
||||
->atPath('begin')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::PROJECT_NOT_STARTED)
|
||||
->addViolation();
|
||||
} elseif (null !== $projectEnd && $timesheetStart->getTimestamp() > $projectEnd->getTimestamp()) {
|
||||
$context->buildViolation('The project is finished at that time.')
|
||||
->atPath('begin')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::PROJECT_ALREADY_ENDED)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $timesheetEnd) {
|
||||
if (null !== $projectEnd && $timesheetEnd->getTimestamp() > $projectEnd->getTimestamp()) {
|
||||
$context->buildViolation('The project is finished at that time.')
|
||||
->atPath('end')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::PROJECT_ALREADY_ENDED)
|
||||
->addViolation();
|
||||
} elseif (null !== $projectBegin && $timesheetEnd->getTimestamp() < $projectBegin->getTimestamp()) {
|
||||
$context->buildViolation('The project has not started at that time.')
|
||||
->atPath('end')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::PROJECT_NOT_STARTED)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,23 @@
|
||||
{{ parent() }}
|
||||
{% endblock form_label %}
|
||||
|
||||
{% block quick_entry_week_row %}
|
||||
<tr{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group qe-entry-week-row' ~ (not valid ? ' has-error'))|trim})} %}{{ block('attributes') }}{% endwith %}>
|
||||
<td>
|
||||
{{ form_row(form.project) }}
|
||||
</td>
|
||||
<td>
|
||||
{{ form_row(form.activity) }}
|
||||
</td>
|
||||
{% for timesheet in form.timesheets %}
|
||||
<td class="text-center{% if timesheet.vars.data.begin is weekend %} weekend{% endif %}{% if timesheet.vars.data.begin is today %} today{% endif %}">
|
||||
{{ form_row(timesheet) }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
<td class="text-nowrap text-center total qe-totals-row"></td>
|
||||
</tr>
|
||||
{% endblock %}
|
||||
|
||||
{% block _team_edit_form_members_entry_user_widget %}
|
||||
{# this will convert the select box into a hidden field, which are exchangable from an HTML perspective #}
|
||||
{%- set type = 'hidden' -%}
|
||||
@@ -47,7 +64,7 @@
|
||||
{% endblock daterange_widget %}
|
||||
|
||||
{% block duration_widget %}
|
||||
{% if form.vars.duration_presets is defined and form.vars.duration_presets is not empty %}
|
||||
{% if (form.vars.duration_presets is defined and form.vars.duration_presets is not empty) and (form.vars.disabled is same as (false)) %}
|
||||
<div class="input-group">
|
||||
{{ block('form_widget_simple') }}
|
||||
<div class="input-group-btn">
|
||||
@@ -55,7 +72,7 @@
|
||||
<ul class="dropdown-menu dropdown-menu-right pre-scrollable">
|
||||
{% for value in form.vars.duration_presets %}
|
||||
<li class="text-center">
|
||||
<a href="#" onclick="$('#{{ form.vars.id }}').val('{{ value }}');$('#{{ form.vars.id }}').trigger('change');return false;">{{ value }}</a>
|
||||
<a href="#" onclick="jQuery('#{{ form.vars.id }}').val('{{ value }}').trigger('change');return false;">{{ value }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -69,7 +86,7 @@
|
||||
{% block datetime_widget -%}
|
||||
<div class="input-group">
|
||||
<div class="input-group-addon">
|
||||
<a href="#" onclick="if (!$('#{{ id }}').is(':disabled')) { $('#{{ id }}').val(moment().format('{{ attr['data-format'] }}')).change(); }return false;"><i class="{{ 'calendar'|icon }}"></i></a>
|
||||
<a href="#" onclick="if (!jQuery('#{{ id }}').is(':disabled')) { jQuery('#{{ id }}').val(moment().format('{{ attr['data-format'] }}')).change(); }return false;"><i class="{{ 'calendar'|icon }}"></i></a>
|
||||
</div>
|
||||
{{ block('form_widget_simple') }}
|
||||
</div>
|
||||
@@ -78,7 +95,7 @@
|
||||
{% block date_widget -%}
|
||||
<div class="input-group">
|
||||
<div class="input-group-addon">
|
||||
<a href="#" onclick="if (!$('#{{ id }}').is(':disabled')) { $('#{{ id }}').val(moment().format('{{ attr['data-format'] }}')).change(); }return false;"><i class="{{ 'calendar'|icon }}"></i></a>
|
||||
<a href="#" onclick="if (!jQuery('#{{ id }}').is(':disabled')) { jQuery('#{{ id }}').val(moment().format('{{ attr['data-format'] }}')).change(); }return false;"><i class="{{ 'calendar'|icon }}"></i></a>
|
||||
</div>
|
||||
{{ block('form_widget_simple') }}
|
||||
</div>
|
||||
@@ -99,7 +116,7 @@
|
||||
|
||||
{% block yearpicker_widget -%}
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-default btn-left" href="#" onclick="$('#{{ form.vars.id }}').val('{{ previousYear|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ previousYear|date_short }}">
|
||||
<a class="btn btn-default btn-left" href="#" onclick="jQuery('#{{ form.vars.id }}').val('{{ previousYear|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ previousYear|date_short }}">
|
||||
<i class="{{ 'left'|icon }}"></i>
|
||||
</a>
|
||||
<a class="btn btn-default" href="#" onclick="return false;">
|
||||
@@ -111,7 +128,7 @@
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
<a class="btn btn-default btn-right" href="#" onclick="$('#{{ form.vars.id }}').val('{{ nextYear|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ nextYear|date_short }}">
|
||||
<a class="btn btn-default btn-right" href="#" onclick="jQuery('#{{ form.vars.id }}').val('{{ nextYear|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ nextYear|date_short }}">
|
||||
<i class="{{ 'right'|icon }}"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -120,13 +137,13 @@
|
||||
|
||||
{% block monthpicker_widget -%}
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-default btn-left" href="#" onclick="$('#{{ form.vars.id }}').val('{{ previousMonth|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ previousMonth|month_name(true) }}">
|
||||
<a class="btn btn-default btn-left" href="#" onclick="jQuery('#{{ form.vars.id }}').val('{{ previousMonth|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ previousMonth|month_name(true) }}">
|
||||
<i class="{{ 'left'|icon }}"></i>
|
||||
</a>
|
||||
<a class="btn btn-default" href="#" onclick="return false;">
|
||||
<span id="{{ form.vars.id }}_month_name">{{ month|month_name(true) }}</span>
|
||||
</a>
|
||||
<a class="btn btn-default btn-right" href="#" onclick="$('#{{ form.vars.id }}').val('{{ nextMonth|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ nextMonth|month_name(true) }}">
|
||||
<a class="btn btn-default btn-right" href="#" onclick="jQuery('#{{ form.vars.id }}').val('{{ nextMonth|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ nextMonth|month_name(true) }}">
|
||||
<i class="{{ 'right'|icon }}"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -134,14 +151,14 @@
|
||||
{%- endblock monthpicker_widget %}
|
||||
|
||||
{% block weekpicker_widget -%}
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-default btn-left" href="#" onclick="$('#{{ form.vars.id }}').val('{{ previousWeek|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ 'stats.workingTimeWeek'|trans({'%week%': previousWeek|date_format('W')}) }}">
|
||||
<div class="btn-group week-picker-btn-group">
|
||||
<a class="btn btn-default btn-left" href="#" onclick="jQuery('#{{ form.vars.id }}').val('{{ previousWeek|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ 'stats.workingTimeWeek'|trans({'%week%': previousWeek|date_format('W')}) }}">
|
||||
<i class="{{ 'left'|icon }}"></i>
|
||||
</a>
|
||||
<a class="btn btn-default" href="#" onclick="return false;">
|
||||
<span id="{{ form.vars.id }}_week_number">{{ 'stats.workingTimeWeek'|trans({'%week%': week|date_format('W')}) }}</span>
|
||||
</a>
|
||||
<a class="btn btn-default btn-right" href="#" onclick="$('#{{ form.vars.id }}').val('{{ nextWeek|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ 'stats.workingTimeWeek'|trans({'%week%': nextWeek|date_format('W')}) }}">
|
||||
<a class="btn btn-default btn-right" href="#" onclick="jQuery('#{{ form.vars.id }}').val('{{ nextWeek|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ 'stats.workingTimeWeek'|trans({'%week%': nextWeek|date_format('W')}) }}">
|
||||
<i class="{{ 'right'|icon }}"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
5
templates/quick-entry/actions.html.twig
Normal file
5
templates/quick-entry/actions.html.twig
Normal file
@@ -0,0 +1,5 @@
|
||||
{% macro quickEntries(view) %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% set event = actions(app.user, 'weekly_times', view, {}) %}
|
||||
{{ widgets.page_actions(event.actions) }}
|
||||
{% endmacro %}
|
||||
127
templates/quick-entry/index.html.twig
Normal file
127
templates/quick-entry/index.html.twig
Normal file
@@ -0,0 +1,127 @@
|
||||
{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %}
|
||||
{% import "quick-entry/actions.html.twig" as actions %}
|
||||
|
||||
{% block page_title %}{{ 'quick_entry.title'|trans }}{% endblock %}
|
||||
{% block page_actions %}{{ actions.quickEntries('index') }}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block box_attributes %}id="quick_entry_box"{% endblock %}
|
||||
{% block box_before %}{{ form_start(form, {attr: {class: 'form-dataTable'}}) }}{% endblock %}
|
||||
{% block box_after %}{{ form_end(form) }}{% endblock %}
|
||||
{% block box_title %}
|
||||
{{ form_widget(form.date) }}
|
||||
{% endblock %}
|
||||
{% block box_body_class %}no-padding{% endblock %}
|
||||
{% block box_footer %}
|
||||
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
|
||||
<button type="button" class="btn btn-success add-item-link" data-collection-prototype="{{ form.rows.vars.id }}" data-collection-holder="ts-collection">
|
||||
<i class="{{ 'create'|icon }}"></i>
|
||||
{{ 'action.add'|trans }}
|
||||
</button>
|
||||
{% endblock %}
|
||||
{% block box_body %}
|
||||
<table class="table dataTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'label.project'|trans }}</th>
|
||||
<th>{{ 'label.activity'|trans }}</th>
|
||||
{% for id, week in days %}
|
||||
<th class="text-center{% if week.day is weekend %} weekend{% endif %}{% if week.day is today %} today{% endif %}">
|
||||
{{ week.day|day_name(true) }}<br>
|
||||
{{ week.day|format_date('short') }}
|
||||
</th>
|
||||
{% endfor %}
|
||||
<th class="summary">{{ 'label.duration'|trans }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ts-collection" data-index="{{ form.rows.children|length }}">
|
||||
{{ form(form) }}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="summary">
|
||||
<td>{{ 'stats.durationTotal'|trans }}</td>
|
||||
<td></td>
|
||||
{% for id, week in days %}
|
||||
<td class="text-center" id="qe-totals-day-{{ loop.index0 }}"></td>
|
||||
{% endfor %}
|
||||
<td class="text-center" id="qe-totals-week"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
{{ parent() }}
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('kimai.initialized', function(event) {
|
||||
const kimai = event.detail.kimai;
|
||||
const DATES = kimai.getPlugin('date');
|
||||
const FORMS = kimai.getPlugin('form-select');
|
||||
|
||||
const recalculateTotals = function(e) {
|
||||
const allFields = document.getElementsByClassName('duration-input');
|
||||
let totalsPerDay = {0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0};
|
||||
let fullTotals = 0;
|
||||
for (let durationInput of allFields) {
|
||||
let id = durationInput.id.replace(/_duration/, '').substr(-1);
|
||||
totalsPerDay[id] += DATES.getSecondsFromDurationString(durationInput.value);
|
||||
}
|
||||
for (const [id, total] of Object.entries(totalsPerDay)) {
|
||||
document.getElementById('qe-totals-day-' + id).innerText = DATES.formatSeconds(total);
|
||||
fullTotals += total;
|
||||
}
|
||||
document.getElementById('qe-totals-week').innerText = DATES.formatSeconds(fullTotals);
|
||||
|
||||
const allRows = document.getElementsByClassName('qe-entry-week-row');
|
||||
for (let qeWeekRow of allRows) {
|
||||
let qeWeekRowFields = qeWeekRow.getElementsByClassName('duration-input');
|
||||
let totalsRow = 0;
|
||||
for (let durationInput of qeWeekRowFields) {
|
||||
totalsRow += DATES.getSecondsFromDurationString(durationInput.value);
|
||||
}
|
||||
qeWeekRow.getElementsByClassName('qe-totals-row')[0].innerText = DATES.formatSeconds(totalsRow);
|
||||
}
|
||||
};
|
||||
recalculateTotals();
|
||||
|
||||
{% if form.user is defined %}
|
||||
jQuery('#{{ form.user.vars.id }}').on('change', function(ev) {
|
||||
jQuery(this).closest('form').submit();
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
jQuery('#{{ form.date.vars.id }}').on('change', function(ev) {
|
||||
location.href = '{{ path('quick_entry', {'begin': '__BEGIN__'}) }}'.replace('__BEGIN__', jQuery(this).val());
|
||||
});
|
||||
|
||||
jQuery('body').on('change', '.duration-input', recalculateTotals);
|
||||
|
||||
const addFormToCollection = function(e) {
|
||||
const collectionHolder = document.getElementById(e.currentTarget.dataset.collectionHolder);
|
||||
const collectionPrototype = document.getElementById(e.currentTarget.dataset.collectionPrototype);
|
||||
|
||||
const node = document.createElement('tr');
|
||||
node.innerHTML = collectionPrototype
|
||||
.dataset
|
||||
.prototype
|
||||
.replace(
|
||||
/__name__/g,
|
||||
collectionHolder.dataset.index
|
||||
);
|
||||
collectionHolder.appendChild(node);
|
||||
|
||||
jQuery(node).find('.selectpicker').each(function(i, el) {
|
||||
FORMS.activateSelectPickerByElement(el, 'body');
|
||||
});
|
||||
|
||||
collectionHolder.dataset.index++;
|
||||
};
|
||||
|
||||
document.querySelectorAll('.add-item-link').forEach(btn => btn.addEventListener("click", addFormToCollection));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -30,7 +30,7 @@
|
||||
<th> </th>
|
||||
<th> </th>
|
||||
{% for day in days.dateTimes %}
|
||||
<th class="text-center text-nowrap{% if day is weekend %} weekend{% endif %}">
|
||||
<th class="text-center text-nowrap{% if day is weekend %} weekend{% endif %}{% if day is today %} today{% endif %}">
|
||||
{{ day|day_name(true) }}<br>
|
||||
{{ day|format_date('short') }}
|
||||
</th>
|
||||
@@ -55,7 +55,7 @@
|
||||
</td>
|
||||
<th class="text-nowrap text-center total">{{ project.duration|duration }}</th>
|
||||
{% for day in project.days.days %}
|
||||
<td class="text-nowrap text-center day-total{% if day.date is weekend %} weekend{% endif %}">
|
||||
<td class="text-nowrap text-center day-total{% if day.date is weekend %} weekend{% endif %}{% if day.date is today %} today{% endif %}">
|
||||
{% if day.duration > 0 %}
|
||||
{% set totals = totals|merge({(day.date|report_date): (totals[day.date|report_date] + day.duration)}) %}
|
||||
<strong>{{ day.duration|duration }}</strong>
|
||||
@@ -70,7 +70,7 @@
|
||||
</td>
|
||||
<th class="text-nowrap text-center total">{{ activity.duration|duration }}</th>
|
||||
{% for day in activity.days.days %}
|
||||
<td class="text-nowrap text-center day-total{% if day.date is weekend %} weekend{% endif %}">
|
||||
<td class="text-nowrap text-center day-total{% if day.date is weekend %} weekend{% endif %}{% if day.date is today %} today{% endif %}">
|
||||
{% if day.duration > 0 %}
|
||||
{{ day.duration|duration }}
|
||||
{% endif %}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<th> </th>
|
||||
<th> </th>
|
||||
{% for day in stats.0.getDateTimes() %}
|
||||
<th class="text-center text-nowrap{% if day is weekend %} weekend{% endif %}">
|
||||
<th class="text-center text-nowrap{% if day is weekend %} weekend{% endif %}{% if day is today %} today{% endif %}">
|
||||
{{ day|day_name(true) }}<br>
|
||||
{{ day|format_date('short') }}
|
||||
</th>
|
||||
@@ -54,7 +54,7 @@
|
||||
<a href="{{ path(subReportRoute, {'date': subReportDate|report_date, 'user': userDay.user.id}) }}">{{ usersTotalDuration|duration }}</a>
|
||||
</th>
|
||||
{% for day in userDay.days %}
|
||||
<td class="text-nowrap text-center day-total{% if day.date is weekend %} weekend{% endif %}">
|
||||
<td class="text-nowrap text-center day-total{% if day.date is weekend %} weekend{% endif %}{% if day.date is today %} today{% endif %}">
|
||||
{% if day.totalDuration > 0 %}
|
||||
{{ day.totalDuration|duration }}
|
||||
{% endif %}
|
||||
|
||||
@@ -527,7 +527,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
|
||||
'begin' => ($dateTime->createDateTime('- 7 hours'))->format('Y-m-d\TH:m:0'),
|
||||
'end' => ($dateTime->createDateTime())->format('Y-m-d\TH:m:0'),
|
||||
'description' => 'foo',
|
||||
'exported' => true,
|
||||
'billable' => false,
|
||||
];
|
||||
$this->request($client, '/api/timesheets/' . $timesheets[0]->getId(), 'PATCH', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -537,7 +537,8 @@ class TimesheetControllerTest extends APIControllerBaseTest
|
||||
self::assertApiResponseTypeStructure('TimesheetEntity', $result);
|
||||
$this->assertNotEmpty($result['id']);
|
||||
$this->assertEquals(25200, $result['duration']);
|
||||
$this->assertEquals(1, $result['exported']);
|
||||
$this->assertEquals('foo', $result['description']);
|
||||
$this->assertFalse($result['billable']);
|
||||
}
|
||||
|
||||
public function testPatchActionWithInvalidUser()
|
||||
|
||||
102
tests/Controller/QuickEntryControllerTest.php
Normal file
102
tests/Controller/QuickEntryControllerTest.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Tests\DataFixtures\TimesheetFixtures;
|
||||
|
||||
/**
|
||||
* @group integration
|
||||
*/
|
||||
class QuickEntryControllerTest extends ControllerBaseTest
|
||||
{
|
||||
public function testIsSecure()
|
||||
{
|
||||
$this->assertUrlIsSecured('/quick_entry');
|
||||
}
|
||||
|
||||
public function testIndexAction()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser();
|
||||
$this->request($client, '/quick_entry');
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$node = $client->getCrawler()->filter('section.content form[name=quick_entry_form]');
|
||||
self::assertEquals(1, $node->filter('div.btn-group.week-picker-btn-group')->count());
|
||||
self::assertEquals(1, $node->filter('input.btn-primary[type=submit]')->count());
|
||||
|
||||
$addBtn = $node->filter('button.btn-success[type=button]');
|
||||
self::assertEquals(1, $addBtn->count());
|
||||
self::assertNotNull($addBtn->attr('data-collection-prototype'));
|
||||
self::assertNotNull($addBtn->attr('data-collection-holder'));
|
||||
|
||||
$rows = $client->getCrawler()->filter('section.content form[name=quick_entry_form] table.dataTable tbody tr:not(.summary)');
|
||||
self::assertEquals(3, $rows->count());
|
||||
$validate = $rows->getIterator()[0];
|
||||
$columns = [];
|
||||
foreach ($validate->childNodes as $childNode) {
|
||||
if ($childNode instanceof \DOMText) {
|
||||
continue;
|
||||
}
|
||||
if ($childNode instanceof \DOMElement && $childNode->tagName === 'td') {
|
||||
$columns[] = $childNode;
|
||||
}
|
||||
}
|
||||
// project + activity + 7 days (duration) + row totals
|
||||
self::assertCount(10, $columns);
|
||||
|
||||
$this->assertPageActions($client, [
|
||||
'back' => $this->createUrl('/timesheet/'),
|
||||
'help' => 'https://www.kimai.org/documentation/weekly-times.html'
|
||||
]);
|
||||
}
|
||||
|
||||
public function testIndexActionWith()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser();
|
||||
|
||||
$fixture = new TimesheetFixtures();
|
||||
$fixture->setAmount(50);
|
||||
$fixture->setUser($this->getUserByRole());
|
||||
$fixture->setStartDate(new \DateTime('-7 days'));
|
||||
$this->importFixture($fixture);
|
||||
|
||||
$this->request($client, '/quick_entry');
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$node = $client->getCrawler()->filter('section.content form[name=quick_entry_form]');
|
||||
self::assertEquals(1, $node->filter('div.btn-group.week-picker-btn-group')->count());
|
||||
self::assertEquals(1, $node->filter('input.btn-primary[type=submit]')->count());
|
||||
|
||||
$addBtn = $node->filter('button.btn-success[type=button]');
|
||||
self::assertEquals(1, $addBtn->count());
|
||||
self::assertNotNull($addBtn->attr('data-collection-prototype'));
|
||||
self::assertNotNull($addBtn->attr('data-collection-holder'));
|
||||
|
||||
$rows = $client->getCrawler()->filter('section.content form[name=quick_entry_form] table.dataTable tbody tr:not(.summary)');
|
||||
self::assertGreaterThanOrEqual(3, $rows->count());
|
||||
$validate = $rows->getIterator()[0];
|
||||
$columns = [];
|
||||
foreach ($validate->childNodes as $childNode) {
|
||||
if ($childNode instanceof \DOMText) {
|
||||
continue;
|
||||
}
|
||||
if ($childNode instanceof \DOMElement && $childNode->tagName === 'td') {
|
||||
$columns[] = $childNode;
|
||||
}
|
||||
}
|
||||
// project + activity + 7 days (duration) + row totals
|
||||
self::assertCount(10, $columns);
|
||||
|
||||
$this->assertPageActions($client, [
|
||||
'back' => $this->createUrl('/timesheet/'),
|
||||
'help' => 'https://www.kimai.org/documentation/weekly-times.html'
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ class TimesheetControllerTest extends ControllerBaseTest
|
||||
'visibility' => '#',
|
||||
'download toolbar-action modal-ajax-form' => $this->createUrl('/timesheet/export/'),
|
||||
'create modal-ajax-form' => $this->createUrl('/timesheet/create'),
|
||||
'quick_entry create-ts' => $this->createUrl('/quick_entry'),
|
||||
'help' => 'https://www.kimai.org/documentation/timesheet.html'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -104,12 +104,8 @@ class TimesheetValidationTest extends KernelTestCase
|
||||
$this->assertHasViolationForField($entity, 'customer');
|
||||
}
|
||||
|
||||
public function testValidationCustomerInvisibleDoesNotTriggerOnStoppedEntites()
|
||||
private function createStoppedTimesheet(Project $project, Activity $activity, ?int $id = null): Timesheet
|
||||
{
|
||||
$customer = (new Customer())->setVisible(false);
|
||||
$project = (new Project())->setName('foo')->setCustomer($customer);
|
||||
$activity = (new Activity())->setName('hello-world')->setProject($project);
|
||||
|
||||
$entity = new Timesheet();
|
||||
$entity
|
||||
->setUser(new User())
|
||||
@@ -119,9 +115,39 @@ class TimesheetValidationTest extends KernelTestCase
|
||||
->setEnd(new \DateTime())
|
||||
;
|
||||
|
||||
if ($id !== null) {
|
||||
$o = new \ReflectionClass($entity);
|
||||
$p = $o->getProperty('id');
|
||||
$p->setAccessible(true);
|
||||
$p->setValue($entity, $id);
|
||||
$p->setAccessible(false);
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
public function testValidationCustomerInvisibleDoesNotTriggerOnStoppedEntities()
|
||||
{
|
||||
$customer = (new Customer())->setVisible(false);
|
||||
$project = (new Project())->setName('foo')->setCustomer($customer);
|
||||
$activity = (new Activity())->setName('hello-world')->setProject($project);
|
||||
|
||||
$entity = $this->createStoppedTimesheet($project, $activity, 99);
|
||||
|
||||
$this->assertHasNoViolations($entity);
|
||||
}
|
||||
|
||||
public function testValidationCustomerInvisibleDoesTriggerOnNewEntities()
|
||||
{
|
||||
$customer = (new Customer())->setVisible(false);
|
||||
$project = (new Project())->setName('foo')->setCustomer($customer);
|
||||
$activity = (new Activity())->setName('hello-world')->setProject($project);
|
||||
|
||||
$entity = $this->createStoppedTimesheet($project, $activity);
|
||||
|
||||
$this->assertHasViolationForField($entity, 'customer');
|
||||
}
|
||||
|
||||
public function testValidationProjectInvisible()
|
||||
{
|
||||
$customer = new Customer();
|
||||
@@ -139,24 +165,28 @@ class TimesheetValidationTest extends KernelTestCase
|
||||
$this->assertHasViolationForField($entity, 'project');
|
||||
}
|
||||
|
||||
public function testValidationProjectInvisibleDoesNotTriggerOnStoppedEntites()
|
||||
public function testValidationProjectInvisibleDoesNotTriggerOnStoppedEntities()
|
||||
{
|
||||
$customer = new Customer();
|
||||
$project = (new Project())->setName('foo')->setCustomer($customer)->setVisible(false);
|
||||
$activity = (new Activity())->setName('hello-world')->setProject($project);
|
||||
|
||||
$entity = new Timesheet();
|
||||
$entity
|
||||
->setUser(new User())
|
||||
->setActivity($activity)
|
||||
->setProject($project)
|
||||
->setBegin(new \DateTime())
|
||||
->setEnd(new \DateTime())
|
||||
;
|
||||
$entity = $this->createStoppedTimesheet($project, $activity, 1);
|
||||
|
||||
$this->assertHasNoViolations($entity);
|
||||
}
|
||||
|
||||
public function testValidationProjectInvisibleDoesTriggerOnNewEntities()
|
||||
{
|
||||
$customer = new Customer();
|
||||
$project = (new Project())->setName('foo')->setCustomer($customer)->setVisible(false);
|
||||
$activity = (new Activity())->setName('hello-world')->setProject($project);
|
||||
|
||||
$entity = $this->createStoppedTimesheet($project, $activity);
|
||||
|
||||
$this->assertHasViolationForField($entity, 'project');
|
||||
}
|
||||
|
||||
public function testValidationActivityInvisible()
|
||||
{
|
||||
$customer = new Customer();
|
||||
@@ -174,24 +204,28 @@ class TimesheetValidationTest extends KernelTestCase
|
||||
$this->assertHasViolationForField($entity, 'activity');
|
||||
}
|
||||
|
||||
public function testValidationActivityInvisibleDoesNotTriggerOnStoppedEntites()
|
||||
public function testValidationActivityInvisibleDoesNotTriggerOnStoppedEntities()
|
||||
{
|
||||
$customer = new Customer();
|
||||
$project = (new Project())->setName('foo')->setCustomer($customer);
|
||||
$activity = (new Activity())->setName('hello-world')->setProject($project)->setVisible(false);
|
||||
|
||||
$entity = new Timesheet();
|
||||
$entity
|
||||
->setUser(new User())
|
||||
->setActivity($activity)
|
||||
->setProject($project)
|
||||
->setBegin(new \DateTime())
|
||||
->setEnd(new \DateTime())
|
||||
;
|
||||
$entity = $this->createStoppedTimesheet($project, $activity, 2);
|
||||
|
||||
$this->assertHasNoViolations($entity);
|
||||
}
|
||||
|
||||
public function testValidationActivityInvisibleDoesTriggerOnNewEntities()
|
||||
{
|
||||
$customer = new Customer();
|
||||
$project = (new Project())->setName('foo')->setCustomer($customer);
|
||||
$activity = (new Activity())->setName('hello-world')->setProject($project)->setVisible(false);
|
||||
|
||||
$entity = $this->createStoppedTimesheet($project, $activity);
|
||||
|
||||
$this->assertHasViolationForField($entity, 'activity');
|
||||
}
|
||||
|
||||
public function testValidationEndNotEarlierThanBegin()
|
||||
{
|
||||
$entity = $this->getEntity();
|
||||
|
||||
23
tests/EventSubscriber/Actions/QuickEntrySubscriberTest.php
Normal file
23
tests/EventSubscriber/Actions/QuickEntrySubscriberTest.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\EventSubscriber\Actions;
|
||||
|
||||
use App\EventSubscriber\Actions\QuickEntrySubscriber;
|
||||
|
||||
/**
|
||||
* @covers \App\EventSubscriber\Actions\QuickEntrySubscriber
|
||||
*/
|
||||
class QuickEntrySubscriberTest extends AbstractActionsSubscriberTest
|
||||
{
|
||||
public function testEventName()
|
||||
{
|
||||
$this->assertGetSubscribedEvent(QuickEntrySubscriber::class, 'weekly_times');
|
||||
}
|
||||
}
|
||||
28
tests/EventSubscriber/MenuSubscriberTest.php
Normal file
28
tests/EventSubscriber/MenuSubscriberTest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\EventSubscriber;
|
||||
|
||||
use App\Event\ConfigureMainMenuEvent;
|
||||
use App\EventSubscriber\MenuSubscriber;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\EventSubscriber\MenuSubscriber
|
||||
*/
|
||||
class MenuSubscriberTest extends TestCase
|
||||
{
|
||||
public function testGetSubscribedEvents()
|
||||
{
|
||||
$events = MenuSubscriber::getSubscribedEvents();
|
||||
$this->assertArrayHasKey(ConfigureMainMenuEvent::class, $events);
|
||||
$methodName = $events[ConfigureMainMenuEvent::class][0];
|
||||
$this->assertTrue(method_exists(MenuSubscriber::class, $methodName));
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ class DurationStringToSecondsTransformerTest extends TestCase
|
||||
['00:00', 0],
|
||||
['0', null],
|
||||
[null, null],
|
||||
['87600000000:00:00', 315360000000000],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -80,6 +81,8 @@ class DurationStringToSecondsTransformerTest extends TestCase
|
||||
['xxx'],
|
||||
[':::'],
|
||||
['0::0'],
|
||||
['87600000000:00:01'],
|
||||
[315360000000001],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -98,4 +98,14 @@ class DurationTypeTest extends TypeTestCase
|
||||
|
||||
self::assertArrayNotHasKey('duration_presets', $view->vars);
|
||||
}
|
||||
|
||||
public function testHasDurationInputClass()
|
||||
{
|
||||
$view = $this->factory->create(DurationType::class, 3600, [
|
||||
'attr' => ['class' => 'testing']
|
||||
])->createView();
|
||||
|
||||
self::assertArrayHasKey('class', $view->vars['attr']);
|
||||
self::assertStringContainsString('duration-input testing', $view->vars['attr']['class']);
|
||||
}
|
||||
}
|
||||
|
||||
133
tests/Form/Type/QuickEntryTimesheetTypeTest.php
Normal file
133
tests/Form/Type/QuickEntryTimesheetTypeTest.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Form\Type;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Form\Type\QuickEntryTimesheetType;
|
||||
use Symfony\Component\Form\PreloadedExtension;
|
||||
use Symfony\Component\Form\Test\TypeTestCase;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
/**
|
||||
* @covers \App\Form\Type\QuickEntryTimesheetType
|
||||
*/
|
||||
class QuickEntryTimesheetTypeTest extends TypeTestCase
|
||||
{
|
||||
protected function getExtensions()
|
||||
{
|
||||
$auth = $this->createMock(Security::class);
|
||||
$auth->method('getUser')->willReturn(new User());
|
||||
$auth->method('isGranted')->willReturn(true);
|
||||
|
||||
$type = new QuickEntryTimesheetType($auth);
|
||||
|
||||
return [
|
||||
new PreloadedExtension([$type], []),
|
||||
];
|
||||
}
|
||||
|
||||
public function getTestData()
|
||||
{
|
||||
yield [4.5, 16200];
|
||||
yield ['4,5', 16200];
|
||||
yield ['4:30', 16200];
|
||||
yield ['4h30m', 16200];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getTestData
|
||||
*/
|
||||
public function testSubmitValidData($value, $expectedDuration)
|
||||
{
|
||||
$data = ['duration' => $value];
|
||||
|
||||
$model = $this->createDefaultModel();
|
||||
|
||||
$form = $this->factory->create(QuickEntryTimesheetType::class, $model);
|
||||
|
||||
$form->submit($data);
|
||||
|
||||
$this->assertTrue($form->isSynchronized());
|
||||
$this->assertEquals($expectedDuration, $model->getDuration());
|
||||
$this->assertEquals($expectedDuration, $model->getDuration(true));
|
||||
}
|
||||
|
||||
private function createDefaultModel(): Timesheet
|
||||
{
|
||||
$begin = new \DateTime('2020-02-15 12:30:00');
|
||||
$end = new \DateTime('2020-02-15 14:00:00');
|
||||
|
||||
$model = new Timesheet();
|
||||
$model->setBegin($begin);
|
||||
$model->setEnd($end);
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
public function testPresetPopulatesView()
|
||||
{
|
||||
$view = $this->factory->create(QuickEntryTimesheetType::class, $this->createDefaultModel(), [
|
||||
'duration_minutes' => 15,
|
||||
'duration_hours' => 5,
|
||||
])->createView();
|
||||
|
||||
$vars = $view->children['duration']->vars;
|
||||
|
||||
self::assertArrayHasKey('duration_presets', $vars);
|
||||
self::assertCount(20, $vars['duration_presets']);
|
||||
self::assertEquals('0:30', $vars['duration_presets'][1]);
|
||||
self::assertEquals('4:45', $vars['duration_presets'][18]);
|
||||
}
|
||||
|
||||
public function testPresetsAreNotGeneratedOnMissingHours()
|
||||
{
|
||||
$view = $this->factory->create(QuickEntryTimesheetType::class, $this->createDefaultModel())->createView();
|
||||
|
||||
$vars = $view->children['duration']->vars;
|
||||
|
||||
self::assertArrayNotHasKey('duration_presets', $vars);
|
||||
}
|
||||
|
||||
public function testPresetsAreNotGeneratedOnMissingMinutes()
|
||||
{
|
||||
$view = $this->factory->create(QuickEntryTimesheetType::class, $this->createDefaultModel(), [
|
||||
'duration_hours' => 5,
|
||||
])->createView();
|
||||
|
||||
$vars = $view->children['duration']->vars;
|
||||
|
||||
self::assertArrayNotHasKey('duration_presets', $vars);
|
||||
}
|
||||
|
||||
public function testPresetsAreNotGeneratedOnNegativeMinutes()
|
||||
{
|
||||
$view = $this->factory->create(QuickEntryTimesheetType::class, $this->createDefaultModel(), [
|
||||
'duration_minutes' => -1,
|
||||
'duration_hours' => 5,
|
||||
])->createView();
|
||||
|
||||
$vars = $view->children['duration']->vars;
|
||||
|
||||
self::assertArrayNotHasKey('duration_presets', $vars);
|
||||
}
|
||||
|
||||
public function testPresetsAreNotGeneratedOnNegativeHours()
|
||||
{
|
||||
$view = $this->factory->create(QuickEntryTimesheetType::class, $this->createDefaultModel(), [
|
||||
'duration_minutes' => 5,
|
||||
'duration_hours' => -1,
|
||||
])->createView();
|
||||
|
||||
$vars = $view->children['duration']->vars;
|
||||
|
||||
self::assertArrayNotHasKey('duration_presets', $vars);
|
||||
}
|
||||
}
|
||||
129
tests/Model/QuickEntryModelTest.php
Normal file
129
tests/Model/QuickEntryModelTest.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Model;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Model\QuickEntryModel;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Model\QuickEntryModel
|
||||
*/
|
||||
class QuickEntryModelTest extends TestCase
|
||||
{
|
||||
public function testEmptyModel()
|
||||
{
|
||||
$sut = new QuickEntryModel();
|
||||
self::assertTrue($sut->isPrototype());
|
||||
self::assertNull($sut->getProject());
|
||||
self::assertNull($sut->getActivity());
|
||||
self::assertNull($sut->getUser());
|
||||
self::assertEquals([], $sut->getNewTimesheet());
|
||||
self::assertEquals([], $sut->getTimesheets());
|
||||
self::assertNull($sut->getLatestEntry());
|
||||
self::assertNull($sut->getFirstEntry());
|
||||
self::assertFalse($sut->hasNewTimesheet());
|
||||
self::assertFalse($sut->hasExistingTimesheet());
|
||||
self::assertFalse($sut->hasTimesheetWithDuration());
|
||||
}
|
||||
|
||||
public function testFullModel()
|
||||
{
|
||||
$user = new User();
|
||||
$project = new Project();
|
||||
$activity = new Activity();
|
||||
|
||||
$sut = new QuickEntryModel($user, $project, $activity);
|
||||
|
||||
self::assertFalse($sut->hasNewTimesheet());
|
||||
$t = new Timesheet();
|
||||
$t->setDuration(null);
|
||||
$sut->addTimesheet($t);
|
||||
self::assertFalse($sut->hasNewTimesheet());
|
||||
$t = new Timesheet();
|
||||
$t->setDuration(1);
|
||||
$sut->addTimesheet($t);
|
||||
self::assertTrue($sut->hasNewTimesheet());
|
||||
self::assertCount(1, $sut->getNewTimesheet());
|
||||
|
||||
self::assertFalse($sut->isPrototype());
|
||||
self::assertSame($project, $sut->getProject());
|
||||
self::assertSame($activity, $sut->getActivity());
|
||||
self::assertSame($user, $sut->getUser());
|
||||
|
||||
$t1 = new Timesheet();
|
||||
$t1->setBegin(new \DateTime('2020-05-30'));
|
||||
$sut->addTimesheet($t1);
|
||||
$t2 = new Timesheet();
|
||||
$t2->setBegin(new \DateTime('2020-01-19'));
|
||||
$sut->addTimesheet($t2);
|
||||
$t3 = new Timesheet();
|
||||
$t3->setBegin(new \DateTime('2020-06-01'));
|
||||
$sut->addTimesheet($t3);
|
||||
$t4 = new Timesheet();
|
||||
$t4->setBegin(new \DateTime('2020-01-09'));
|
||||
$sut->addTimesheet($t4);
|
||||
self::assertSame($t3, $sut->getLatestEntry());
|
||||
self::assertEquals('2020-06-01', $sut->getLatestEntry()->getBegin()->format('Y-m-d'));
|
||||
self::assertSame($t4, $sut->getFirstEntry());
|
||||
self::assertEquals('2020-01-09', $sut->getFirstEntry()->getBegin()->format('Y-m-d'));
|
||||
|
||||
self::assertCount(5, $sut->getNewTimesheet());
|
||||
self::assertCount(6, $sut->getTimesheets());
|
||||
|
||||
self::assertFalse($sut->hasExistingTimesheet());
|
||||
self::assertTrue($sut->hasTimesheetWithDuration());
|
||||
|
||||
$sut->setProject(null);
|
||||
self::assertNull($sut->getProject());
|
||||
$project2 = new Project();
|
||||
$sut->setProject($project2);
|
||||
self::assertSame($project2, $sut->getProject());
|
||||
|
||||
$sut->setActivity(null);
|
||||
self::assertNull($sut->getActivity());
|
||||
$activity2 = new Activity();
|
||||
$sut->setActivity($activity2);
|
||||
self::assertSame($activity2, $sut->getActivity());
|
||||
|
||||
$sut->setTimesheets([$t1, $t2, $t3, $t4]);
|
||||
self::assertCount(4, $sut->getNewTimesheet());
|
||||
self::assertCount(4, $sut->getTimesheets());
|
||||
}
|
||||
|
||||
public function testHasExistingTimesheet()
|
||||
{
|
||||
$sut = new QuickEntryModel();
|
||||
|
||||
self::assertTrue($sut->isPrototype());
|
||||
self::assertFalse($sut->hasExistingTimesheet());
|
||||
$mock = $this->createMock(Timesheet::class);
|
||||
$mock->method('getId')->willReturn(1);
|
||||
$sut->addTimesheet($mock);
|
||||
self::assertTrue($sut->hasExistingTimesheet());
|
||||
self::assertFalse($sut->isPrototype());
|
||||
}
|
||||
|
||||
public function testDefaultModel()
|
||||
{
|
||||
$user = new User();
|
||||
$project = new Project();
|
||||
$activity = new Activity();
|
||||
|
||||
$sut = new QuickEntryModel($user, $project, $activity);
|
||||
self::assertFalse($sut->isPrototype());
|
||||
self::assertSame($project, $sut->getProject());
|
||||
self::assertSame($activity, $sut->getActivity());
|
||||
self::assertSame($user, $sut->getUser());
|
||||
}
|
||||
}
|
||||
37
tests/Model/QuickEntryWeekTest.php
Normal file
37
tests/Model/QuickEntryWeekTest.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Model;
|
||||
|
||||
use App\Model\QuickEntryModel;
|
||||
use App\Model\QuickEntryWeek;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Model\QuickEntryWeek
|
||||
*/
|
||||
class QuickEntryWeekTest extends TestCase
|
||||
{
|
||||
public function testModel()
|
||||
{
|
||||
$date = new \DateTime();
|
||||
$rows = [];
|
||||
|
||||
$sut = new QuickEntryWeek($date, $rows);
|
||||
self::assertSame($date, $sut->getDate());
|
||||
self::assertEquals([], $sut->getRows());
|
||||
|
||||
$rows = [
|
||||
new QuickEntryModel()
|
||||
];
|
||||
|
||||
$sut->setRows($rows);
|
||||
self::assertSame($rows, $sut->getRows());
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,18 @@ class ProjectFormTypeQueryTest extends BaseFormTypeQueryTest
|
||||
$sut->setWithCustomer(true);
|
||||
self::assertTrue($sut->withCustomer());
|
||||
self::assertNull($sut->getProjectToIgnore());
|
||||
self::assertInstanceOf(ProjectFormTypeQuery::class, $sut->setProjectToIgnore($project));
|
||||
$sut->setProjectToIgnore($project);
|
||||
self::assertSame($project, $sut->getProjectToIgnore());
|
||||
|
||||
self::assertNotNull($sut->getProjectStart());
|
||||
self::assertNotNull($sut->getProjectEnd());
|
||||
|
||||
$date = new \DateTime('2019-04-20');
|
||||
$sut->setProjectStart($date);
|
||||
self::assertSame($date, $sut->getProjectStart());
|
||||
|
||||
$date = new \DateTime('2020-01-01');
|
||||
$sut->setProjectEnd($date);
|
||||
self::assertSame($date, $sut->getProjectEnd());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Intl\Util\IntlTestHelper;
|
||||
use Twig\TwigFilter;
|
||||
use Twig\TwigFunction;
|
||||
use Twig\TwigTest;
|
||||
|
||||
/**
|
||||
* @covers \App\Twig\LocaleFormatExtensions
|
||||
@@ -82,6 +83,22 @@ class LocaleFormatExtensionsTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testGetTests()
|
||||
{
|
||||
$tests = ['weekend', 'today'];
|
||||
$i = 0;
|
||||
|
||||
$sut = $this->getSut('de', []);
|
||||
$twigTests = $sut->getTests();
|
||||
$this->assertCount(\count($tests), $twigTests);
|
||||
|
||||
/** @var TwigTest $test */
|
||||
foreach ($twigTests as $test) {
|
||||
$this->assertInstanceOf(TwigTest::class, $test);
|
||||
$this->assertEquals($tests[$i++], $test->getName());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $locale
|
||||
* @param \DateTime|string $date
|
||||
@@ -494,6 +511,39 @@ class LocaleFormatExtensionsTest extends TestCase
|
||||
$this->assertEquals('0.00', $sut->durationDecimal(null));
|
||||
}
|
||||
|
||||
private function getTest(string $name): TwigTest
|
||||
{
|
||||
$sut = $this->getSut('en', $this->localeEn);
|
||||
foreach ($sut->getTests() as $test) {
|
||||
if ($test->getName() === $name) {
|
||||
return $test;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \Exception('Unknown twig test: ' . $name);
|
||||
}
|
||||
|
||||
public function testIsToday()
|
||||
{
|
||||
$test = $this->getTest('today');
|
||||
self::assertTrue(\call_user_func($test->getCallable(), new \DateTime()));
|
||||
self::assertFalse(\call_user_func($test->getCallable(), new \DateTime('-1 day')));
|
||||
self::assertFalse(\call_user_func($test->getCallable(), new \DateTime('+1 day')));
|
||||
self::assertFalse(\call_user_func($test->getCallable(), new \stdClass()));
|
||||
self::assertFalse(\call_user_func($test->getCallable(), null));
|
||||
}
|
||||
|
||||
public function testIsWeekend()
|
||||
{
|
||||
$test = $this->getTest('weekend');
|
||||
self::assertTrue(\call_user_func($test->getCallable(), new \DateTime('first saturday this month')));
|
||||
self::assertTrue(\call_user_func($test->getCallable(), new \DateTime('first sunday this month')));
|
||||
self::assertFalse(\call_user_func($test->getCallable(), new \DateTime('first monday this month')));
|
||||
self::assertFalse(\call_user_func($test->getCallable(), new \DateTime('first friday this month')));
|
||||
self::assertFalse(\call_user_func($test->getCallable(), new \stdClass()));
|
||||
self::assertFalse(\call_user_func($test->getCallable(), null));
|
||||
}
|
||||
|
||||
protected function getTimesheet($seconds)
|
||||
{
|
||||
$begin = new \DateTime();
|
||||
|
||||
@@ -16,6 +16,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\ColorChoices
|
||||
* @covers \App\Validator\Constraints\ColorChoicesValidator
|
||||
*/
|
||||
class ColorChoicesValidatorTest extends ConstraintValidatorTestCase
|
||||
|
||||
@@ -16,6 +16,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\DateTimeFormat
|
||||
* @covers \App\Validator\Constraints\DateTimeFormatValidator
|
||||
*/
|
||||
class DateTimeFormatValidatorTest extends ConstraintValidatorTestCase
|
||||
|
||||
@@ -17,6 +17,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\Duration
|
||||
* @covers \App\Validator\Constraints\DurationValidator
|
||||
*/
|
||||
class DurationValidatorTest extends ConstraintValidatorTestCase
|
||||
|
||||
@@ -16,6 +16,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\HexColor
|
||||
* @covers \App\Validator\Constraints\HexColorValidator
|
||||
*/
|
||||
class HexColorValidatorTest extends ConstraintValidatorTestCase
|
||||
|
||||
@@ -17,6 +17,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\Project
|
||||
* @covers \App\Validator\Constraints\ProjectValidator
|
||||
*/
|
||||
class ProjectValidatorTest extends ConstraintValidatorTestCase
|
||||
@@ -48,4 +49,10 @@ class ProjectValidatorTest extends ConstraintValidatorTestCase
|
||||
->setCode(ProjectConstraint::END_BEFORE_BEGIN_ERROR)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testGetTargets()
|
||||
{
|
||||
$constraint = new ProjectConstraint();
|
||||
self::assertEquals('class', $constraint->getTargets());
|
||||
}
|
||||
}
|
||||
|
||||
123
tests/Validator/Constraints/QuickEntryModelValidatorTest.php
Normal file
123
tests/Validator/Constraints/QuickEntryModelValidatorTest.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Validator\Constraints;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Model\QuickEntryModel as QuickEntryModelEntity;
|
||||
use App\Validator\Constraints\QuickEntryModel;
|
||||
use App\Validator\Constraints\QuickEntryModelValidator;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\QuickEntryModel
|
||||
* @covers \App\Validator\Constraints\QuickEntryModelValidator
|
||||
*/
|
||||
class QuickEntryModelValidatorTest extends ConstraintValidatorTestCase
|
||||
{
|
||||
protected function createValidator()
|
||||
{
|
||||
return new QuickEntryModelValidator();
|
||||
}
|
||||
|
||||
public function testConstraintIsInvalid()
|
||||
{
|
||||
$this->expectException(UnexpectedTypeException::class);
|
||||
|
||||
$this->validator->validate(new Timesheet(), new NotBlank());
|
||||
}
|
||||
|
||||
public function testInvalidValueThrowsException()
|
||||
{
|
||||
$this->expectException(UnexpectedTypeException::class);
|
||||
|
||||
$this->validator->validate(new Timesheet(), new QuickEntryModel());
|
||||
}
|
||||
|
||||
public function testTriggersOnMissingProjectAndActivity()
|
||||
{
|
||||
$model = new QuickEntryModelEntity();
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setBegin(new \DateTime());
|
||||
$timesheet->setBegin(new \DateTime('+ 1 hour'));
|
||||
$model->addTimesheet($timesheet);
|
||||
|
||||
$this->validator->validate($model, new QuickEntryModel());
|
||||
|
||||
$this->buildViolation('An activity needs to be selected.')
|
||||
->atPath('property.path.activity')
|
||||
->setCode(QuickEntryModel::ACTIVITY_REQUIRED)
|
||||
->buildNextViolation('A project needs to be selected.')
|
||||
->atPath('property.path.project')
|
||||
->setCode(QuickEntryModel::PROJECT_REQUIRED)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testTriggersOnMissingActivity()
|
||||
{
|
||||
$model = new QuickEntryModelEntity();
|
||||
$model->setProject(new Project());
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setBegin(new \DateTime());
|
||||
$timesheet->setBegin(new \DateTime('+ 1 hour'));
|
||||
$model->addTimesheet($timesheet);
|
||||
|
||||
$this->validator->validate($model, new QuickEntryModel());
|
||||
|
||||
$this->buildViolation('An activity needs to be selected.')
|
||||
->atPath('property.path.activity')
|
||||
->setCode(QuickEntryModel::ACTIVITY_REQUIRED)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testTriggersOnMissingProject()
|
||||
{
|
||||
$model = new QuickEntryModelEntity();
|
||||
$model->setActivity(new Activity());
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setBegin(new \DateTime());
|
||||
$timesheet->setBegin(new \DateTime('+ 1 hour'));
|
||||
$model->addTimesheet($timesheet);
|
||||
|
||||
$this->validator->validate($model, new QuickEntryModel());
|
||||
|
||||
$this->buildViolation('A project needs to be selected.')
|
||||
->atPath('property.path.project')
|
||||
->setCode(QuickEntryModel::PROJECT_REQUIRED)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testDoesNotTriggerOnPrototype()
|
||||
{
|
||||
$model = new QuickEntryModelEntity();
|
||||
|
||||
$this->validator->validate($model, new QuickEntryModel());
|
||||
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testDoesNotTriggerOnProperlyFilled()
|
||||
{
|
||||
$model = new QuickEntryModelEntity();
|
||||
$model->setActivity(new Activity());
|
||||
$model->setProject(new Project());
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setBegin(new \DateTime());
|
||||
$timesheet->setBegin(new \DateTime('+ 1 hour'));
|
||||
$model->addTimesheet($timesheet);
|
||||
|
||||
$this->validator->validate($model, new QuickEntryModel());
|
||||
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Validator\Constraints;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Validator\Constraints\QuickEntryTimesheet;
|
||||
use App\Validator\Constraints\QuickEntryTimesheetValidator;
|
||||
use App\Validator\Constraints\TimesheetBasic;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\QuickEntryTimesheet
|
||||
* @covers \App\Validator\Constraints\QuickEntryTimesheetValidator
|
||||
*/
|
||||
class QuickEntryTimesheetValidatorTest extends ConstraintValidatorTestCase
|
||||
{
|
||||
protected function createConstraint(): Constraint
|
||||
{
|
||||
return new QuickEntryTimesheet();
|
||||
}
|
||||
|
||||
protected function createValidator()
|
||||
{
|
||||
return new QuickEntryTimesheetValidator([new TimesheetBasic()]);
|
||||
}
|
||||
|
||||
public function testConstraintIsInvalid()
|
||||
{
|
||||
$this->expectException(UnexpectedTypeException::class);
|
||||
|
||||
$this->validator->validate(new Timesheet(), new NotBlank());
|
||||
}
|
||||
|
||||
public function testInvalidValueThrowsException()
|
||||
{
|
||||
$this->expectException(UnexpectedTypeException::class);
|
||||
|
||||
$this->validator->validate(new Activity(), $this->createConstraint());
|
||||
}
|
||||
|
||||
public function testNotTriggersOnEmptyDurationAndNewTimesheet()
|
||||
{
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setDuration(null);
|
||||
|
||||
$this->validator->validate($timesheet, $this->createConstraint());
|
||||
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\Role
|
||||
* @covers \App\Validator\Constraints\RoleValidator
|
||||
*/
|
||||
class RoleValidatorTest extends ConstraintValidatorTestCase
|
||||
|
||||
@@ -19,6 +19,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\Team
|
||||
* @covers \App\Validator\Constraints\TeamValidator
|
||||
*/
|
||||
class TeamValidatorTest extends ConstraintValidatorTestCase
|
||||
|
||||
@@ -17,6 +17,7 @@ use Symfony\Component\Validator\Exception\UnexpectedValueException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\TimeFormat
|
||||
* @covers \App\Validator\Constraints\TimeFormatValidator
|
||||
*/
|
||||
class TimeFormatValidatorTest extends ConstraintValidatorTestCase
|
||||
|
||||
243
tests/Validator/Constraints/TimesheetBasicValidatorTest.php
Normal file
243
tests/Validator/Constraints/TimesheetBasicValidatorTest.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Validator\Constraints;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Validator\Constraints\TimesheetBasic;
|
||||
use App\Validator\Constraints\TimesheetBasicValidator;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\TimesheetBasic
|
||||
* @covers \App\Validator\Constraints\TimesheetBasicValidator
|
||||
*/
|
||||
class TimesheetBasicValidatorTest extends ConstraintValidatorTestCase
|
||||
{
|
||||
protected function createValidator()
|
||||
{
|
||||
return $this->createMyValidator();
|
||||
}
|
||||
|
||||
protected function createMyValidator()
|
||||
{
|
||||
return new TimesheetBasicValidator();
|
||||
}
|
||||
|
||||
public function testConstraintIsInvalid()
|
||||
{
|
||||
$this->expectException(UnexpectedTypeException::class);
|
||||
|
||||
$this->validator->validate(new Timesheet(), new NotBlank());
|
||||
}
|
||||
|
||||
public function testInvalidValueThrowsException()
|
||||
{
|
||||
$this->expectException(UnexpectedTypeException::class);
|
||||
|
||||
$this->validator->validate(new NotBlank(), new TimesheetBasic(['message' => 'myMessage']));
|
||||
}
|
||||
|
||||
public function testEmptyTimesheet()
|
||||
{
|
||||
$timesheet = new Timesheet();
|
||||
$this->validator->validate($timesheet, new TimesheetBasic(['message' => 'myMessage']));
|
||||
|
||||
$this->buildViolation('You must submit a begin date.')
|
||||
->atPath('property.path.begin')
|
||||
->setCode(TimesheetBasic::MISSING_BEGIN_ERROR)
|
||||
->buildNextViolation('An activity needs to be selected.')
|
||||
->atPath('property.path.activity')
|
||||
->setCode(TimesheetBasic::MISSING_ACTIVITY_ERROR)
|
||||
->buildNextViolation('A project needs to be selected.')
|
||||
->atPath('property.path.project')
|
||||
->setCode(TimesheetBasic::MISSING_PROJECT_ERROR)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testFutureBegin()
|
||||
{
|
||||
$begin = new \DateTime('+10 hour');
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setBegin($begin);
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetBasic(['message' => 'myMessage']));
|
||||
|
||||
$this
|
||||
->buildViolation('An activity needs to be selected.')
|
||||
->atPath('property.path.activity')
|
||||
->setCode(TimesheetBasic::MISSING_ACTIVITY_ERROR)
|
||||
->buildNextViolation('A project needs to be selected.')
|
||||
->atPath('property.path.project')
|
||||
->setCode(TimesheetBasic::MISSING_PROJECT_ERROR)
|
||||
// The test context is not able to handle calls to validate() - see ConstraintValidatorTestCase::createContext()
|
||||
// therefor sub-constraints will not be executed :-(
|
||||
/*
|
||||
->buildNextViolation('The begin date cannot be in the future.')
|
||||
->atPath('property.path.begin')
|
||||
->setCode(TimesheetFutureTimes::BEGIN_IN_FUTURE_ERROR)
|
||||
*/
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testEndBeforeBegin()
|
||||
{
|
||||
$end = new \DateTime('-10 hour');
|
||||
$begin = new \DateTime('-1 hour');
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setBegin($begin);
|
||||
$timesheet->setEnd($end);
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetBasic(['message' => 'myMessage']));
|
||||
|
||||
$this->buildViolation('End date must not be earlier then start date.')
|
||||
->atPath('property.path.end')
|
||||
->setCode(TimesheetBasic::END_BEFORE_BEGIN_ERROR)
|
||||
->buildNextViolation('An activity needs to be selected.')
|
||||
->atPath('property.path.activity')
|
||||
->setCode(TimesheetBasic::MISSING_ACTIVITY_ERROR)
|
||||
->buildNextViolation('A project needs to be selected.')
|
||||
->atPath('property.path.project')
|
||||
->setCode(TimesheetBasic::MISSING_PROJECT_ERROR)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testProjectMismatch()
|
||||
{
|
||||
$end = new \DateTime('-1 hour');
|
||||
$begin = new \DateTime('-10 hour');
|
||||
$activity = new Activity();
|
||||
$project1 = new Project();
|
||||
$project2 = new Project();
|
||||
$project2->setCustomer(new Customer());
|
||||
$activity->setProject($project1);
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet
|
||||
->setBegin($begin)
|
||||
->setEnd($end)
|
||||
->setActivity($activity)
|
||||
->setProject($project2)
|
||||
;
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetBasic(['message' => 'myMessage']));
|
||||
|
||||
$this->buildViolation('Project mismatch, project specific activity and timesheet project are different.')
|
||||
->atPath('property.path.project')
|
||||
->setCode(TimesheetBasic::ACTIVITY_PROJECT_MISMATCH_ERROR)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testDisabledValuesDuringStart()
|
||||
{
|
||||
$begin = new \DateTime('-10 hour');
|
||||
$customer = new Customer();
|
||||
$customer->setVisible(false);
|
||||
$activity = new Activity();
|
||||
$activity->setVisible(false);
|
||||
$project = new Project();
|
||||
$project->setVisible(false);
|
||||
$project->setCustomer($customer);
|
||||
$activity->setProject($project);
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet
|
||||
->setBegin($begin)
|
||||
->setActivity($activity)
|
||||
->setProject($project)
|
||||
;
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetBasic(['message' => 'myMessage']));
|
||||
|
||||
$this->buildViolation('Cannot start a disabled activity.')
|
||||
->atPath('property.path.activity')
|
||||
->setCode(TimesheetBasic::DISABLED_ACTIVITY_ERROR)
|
||||
->buildNextViolation('Cannot start a disabled project.')
|
||||
->atPath('property.path.project')
|
||||
->setCode(TimesheetBasic::DISABLED_PROJECT_ERROR)
|
||||
->buildNextViolation('Cannot start a disabled customer.')
|
||||
->atPath('property.path.customer')
|
||||
->setCode(TimesheetBasic::DISABLED_CUSTOMER_ERROR)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function getProjectStartEndTestData()
|
||||
{
|
||||
yield [new \DateTime(), new \DateTime(), [
|
||||
['begin', TimesheetBasic::PROJECT_NOT_STARTED, 'The project has not started at that time.'],
|
||||
['end', TimesheetBasic::PROJECT_NOT_STARTED, 'The project has not started at that time.'],
|
||||
]];
|
||||
|
||||
yield [new \DateTime('-9 hour'), new \DateTime('-2 hour'), [
|
||||
['begin', TimesheetBasic::PROJECT_NOT_STARTED, 'The project has not started at that time.'],
|
||||
['end', TimesheetBasic::PROJECT_ALREADY_ENDED, 'The project is finished at that time.'],
|
||||
]];
|
||||
|
||||
yield [new \DateTime('-19 hour'), new \DateTime('-12 hour'), [
|
||||
['begin', TimesheetBasic::PROJECT_ALREADY_ENDED, 'The project is finished at that time.'],
|
||||
['end', TimesheetBasic::PROJECT_ALREADY_ENDED, 'The project is finished at that time.'],
|
||||
]];
|
||||
|
||||
yield [new \DateTime('-19 hour'), new \DateTime('-2 hour'), [
|
||||
['end', TimesheetBasic::PROJECT_ALREADY_ENDED, 'The project is finished at that time.'],
|
||||
]];
|
||||
|
||||
yield [new \DateTime('-9 hour'), new \DateTime(), [
|
||||
['begin', TimesheetBasic::PROJECT_NOT_STARTED, 'The project has not started at that time.'],
|
||||
]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getProjectStartEndTestData
|
||||
*/
|
||||
public function testEndBeforeWithProjectStartAndEnd(\DateTime $start, \DateTime $end, array $violations)
|
||||
{
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setBegin(new \DateTime('-10 hour'));
|
||||
$timesheet->setEnd(new \DateTime('-1 hour'));
|
||||
|
||||
$customer = new Customer();
|
||||
$project = new Project();
|
||||
$project->setStart($start);
|
||||
$project->setEnd($end);
|
||||
$project->setCustomer($customer);
|
||||
|
||||
$timesheet->setProject($project);
|
||||
$timesheet->setActivity(new Activity());
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetBasic(['message' => 'myMessage']));
|
||||
|
||||
$assertion = null;
|
||||
foreach ($violations as $violation) {
|
||||
if (null === $assertion) {
|
||||
$assertion = $this->buildViolation($violation[2])
|
||||
->atPath('property.path.' . $violation[0])
|
||||
->setCode($violation[1])
|
||||
;
|
||||
} else {
|
||||
$assertion = $assertion->buildNextViolation($violation[2])
|
||||
->atPath('property.path.' . $violation[0])
|
||||
->setCode($violation[1])
|
||||
;
|
||||
}
|
||||
}
|
||||
$assertion->assertRaised();
|
||||
}
|
||||
|
||||
public function testGetTargets()
|
||||
{
|
||||
$constraint = new TimesheetBasic();
|
||||
self::assertEquals('class', $constraint->getTargets());
|
||||
}
|
||||
}
|
||||
111
tests/Validator/Constraints/TimesheetExportedValidatorTest.php
Normal file
111
tests/Validator/Constraints/TimesheetExportedValidatorTest.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Validator\Constraints;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Validator\Constraints\TimesheetExported;
|
||||
use App\Validator\Constraints\TimesheetExportedValidator;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\TimesheetExported
|
||||
* @covers \App\Validator\Constraints\TimesheetExportedValidator
|
||||
*/
|
||||
class TimesheetExportedValidatorTest extends ConstraintValidatorTestCase
|
||||
{
|
||||
protected function createValidator()
|
||||
{
|
||||
return $this->createMyValidator(true);
|
||||
}
|
||||
|
||||
protected function createMyValidator(bool $allowEdit)
|
||||
{
|
||||
$auth = $this->createMock(Security::class);
|
||||
$auth->method('getUser')->willReturn(new User());
|
||||
$auth->method('isGranted')->willReturnCallback(
|
||||
function ($attributes, $subject = null) use ($allowEdit) {
|
||||
switch ($attributes) {
|
||||
case 'edit_exported_timesheet':
|
||||
return $allowEdit;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
return new TimesheetExportedValidator($auth);
|
||||
}
|
||||
|
||||
public function testConstraintIsInvalid()
|
||||
{
|
||||
$this->expectException(UnexpectedTypeException::class);
|
||||
|
||||
$this->validator->validate(new Timesheet(), new NotBlank());
|
||||
}
|
||||
|
||||
public function testInvalidValueThrowsException()
|
||||
{
|
||||
$this->expectException(UnexpectedTypeException::class);
|
||||
|
||||
$this->validator->validate(new NotBlank(), new TimesheetExported(['message' => 'myMessage']));
|
||||
}
|
||||
|
||||
public function testTriggersOnMissingPermission()
|
||||
{
|
||||
$this->validator = $this->createMyValidator(false);
|
||||
$this->validator->initialize($this->context);
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setExported(true);
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetExported());
|
||||
|
||||
$this->buildViolation('This timesheet is already exported.')
|
||||
->atPath('property.path.exported')
|
||||
->setCode(TimesheetExported::TIMESHEET_EXPORTED)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testDoesNotTriggerWithPermission()
|
||||
{
|
||||
$this->validator = $this->createMyValidator(true);
|
||||
$this->validator->initialize($this->context);
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setExported(true);
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetExported());
|
||||
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testDoesNotTriggerIfNotExported()
|
||||
{
|
||||
$this->validator = $this->createMyValidator(false);
|
||||
$this->validator->initialize($this->context);
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setExported(false);
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetExported());
|
||||
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testGetTargets()
|
||||
{
|
||||
$constraint = new TimesheetExported();
|
||||
self::assertEquals('class', $constraint->getTargets());
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\TimesheetFutureTimes
|
||||
* @covers \App\Validator\Constraints\TimesheetFutureTimesValidator
|
||||
*/
|
||||
class TimesheetFutureTimesValidatorTest extends ConstraintValidatorTestCase
|
||||
|
||||
@@ -22,6 +22,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\TimesheetLockdown
|
||||
* @covers \App\Validator\Constraints\TimesheetLockdownValidator
|
||||
*/
|
||||
class TimesheetLockdownValidatorTest extends ConstraintValidatorTestCase
|
||||
|
||||
@@ -19,6 +19,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\TimesheetLongRunning
|
||||
* @covers \App\Validator\Constraints\TimesheetLongRunningValidator
|
||||
*/
|
||||
class TimesheetLongRunningValidatorTest extends ConstraintValidatorTestCase
|
||||
@@ -73,6 +74,33 @@ class TimesheetLongRunningValidatorTest extends ConstraintValidatorTestCase
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testLongRunningTriggersOverMaximum()
|
||||
{
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setBegin(new \DateTime());
|
||||
$timesheet->setEnd(new \DateTime());
|
||||
$timesheet->setDuration(31536001);
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetLongRunning());
|
||||
|
||||
$this->buildViolation('Maximum duration exceeded.')
|
||||
->atPath('property.path.duration')
|
||||
->setCode(TimesheetLongRunning::MAXIMUM)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testLongRunningDoesNotTriggerOnMaximum()
|
||||
{
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setBegin(new \DateTime());
|
||||
$timesheet->setEnd(new \DateTime());
|
||||
$timesheet->setDuration(31536000);
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetLongRunning());
|
||||
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testLongRunningNotTriggersIfConfiguredToZero()
|
||||
{
|
||||
$this->validator = $this->createMyValidator(0);
|
||||
@@ -114,4 +142,10 @@ class TimesheetLongRunningValidatorTest extends ConstraintValidatorTestCase
|
||||
$this->validator->validate($timesheet, new TimesheetLongRunning());
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testGetTargets()
|
||||
{
|
||||
$constraint = new TimesheetLongRunning();
|
||||
self::assertEquals('class', $constraint->getTargets());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\TimesheetMultiUpdate
|
||||
* @covers \App\Validator\Constraints\TimesheetMultiUpdateValidator
|
||||
*/
|
||||
class TimesheetMultiUpdateValidatorTest extends ConstraintValidatorTestCase
|
||||
|
||||
@@ -17,6 +17,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\TimesheetMultiUser
|
||||
* @covers \App\Validator\Constraints\TimesheetMultiUserValidator
|
||||
*/
|
||||
class TimesheetMultiUserValidatorTest extends ConstraintValidatorTestCase
|
||||
|
||||
@@ -20,6 +20,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\TimesheetOverlapping
|
||||
* @covers \App\Validator\Constraints\TimesheetOverlappingValidator
|
||||
*/
|
||||
class TimesheetOverlappingValidatorTest extends ConstraintValidatorTestCase
|
||||
|
||||
@@ -24,6 +24,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\TimesheetRestart
|
||||
* @covers \App\Validator\Constraints\TimesheetRestartValidator
|
||||
*/
|
||||
class TimesheetRestartValidatorTest extends ConstraintValidatorTestCase
|
||||
|
||||
@@ -9,19 +9,15 @@
|
||||
|
||||
namespace App\Tests\Validator\Constraints;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Validator\Constraints\Timesheet as TimesheetConstraint;
|
||||
use App\Validator\Constraints\TimesheetFutureTimes;
|
||||
use App\Validator\Constraints\TimesheetValidator;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
use Symfony\Component\Validator\Test\ConstraintViolationAssertion;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\Timesheet
|
||||
* @covers \App\Validator\Constraints\TimesheetValidator
|
||||
*/
|
||||
class TimesheetValidatorTest extends ConstraintValidatorTestCase
|
||||
@@ -49,190 +45,4 @@ class TimesheetValidatorTest extends ConstraintValidatorTestCase
|
||||
|
||||
$this->validator->validate(new NotBlank(), new TimesheetConstraint(['message' => 'myMessage']));
|
||||
}
|
||||
|
||||
public function testEmptyTimesheet()
|
||||
{
|
||||
$timesheet = new Timesheet();
|
||||
$this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
|
||||
|
||||
$this->buildViolation('You must submit a begin date.')
|
||||
->atPath('property.path.begin')
|
||||
->setCode(TimesheetConstraint::MISSING_BEGIN_ERROR)
|
||||
->buildNextViolation('A timesheet must have an activity.')
|
||||
->atPath('property.path.activity')
|
||||
->setCode(TimesheetConstraint::MISSING_ACTIVITY_ERROR)
|
||||
->buildNextViolation('A timesheet must have a project.')
|
||||
->atPath('property.path.project')
|
||||
->setCode(TimesheetConstraint::MISSING_PROJECT_ERROR)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testFutureBegin()
|
||||
{
|
||||
$begin = new \DateTime('+10 hour');
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setBegin($begin);
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
|
||||
|
||||
$this
|
||||
->buildViolation('A timesheet must have an activity.')
|
||||
->atPath('property.path.activity')
|
||||
->setCode(TimesheetConstraint::MISSING_ACTIVITY_ERROR)
|
||||
->buildNextViolation('A timesheet must have a project.')
|
||||
->atPath('property.path.project')
|
||||
->setCode(TimesheetConstraint::MISSING_PROJECT_ERROR)
|
||||
// The test context is not able to handle calls to validate() - see ConstraintValidatorTestCase::createContext()
|
||||
// therefor sub-constraints will not be executed :-(
|
||||
/*
|
||||
->buildNextViolation('The begin date cannot be in the future.')
|
||||
->atPath('property.path.begin')
|
||||
->setCode(TimesheetFutureTimes::BEGIN_IN_FUTURE_ERROR)
|
||||
*/
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testEndBeforeBegin()
|
||||
{
|
||||
$end = new \DateTime('-10 hour');
|
||||
$begin = new \DateTime('-1 hour');
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setBegin($begin);
|
||||
$timesheet->setEnd($end);
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
|
||||
|
||||
$this->buildViolation('End date must not be earlier then start date.')
|
||||
->atPath('property.path.end')
|
||||
->setCode(TimesheetConstraint::END_BEFORE_BEGIN_ERROR)
|
||||
->buildNextViolation('A timesheet must have an activity.')
|
||||
->atPath('property.path.activity')
|
||||
->setCode(TimesheetConstraint::MISSING_ACTIVITY_ERROR)
|
||||
->buildNextViolation('A timesheet must have a project.')
|
||||
->atPath('property.path.project')
|
||||
->setCode(TimesheetConstraint::MISSING_PROJECT_ERROR)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testProjectMismatch()
|
||||
{
|
||||
$end = new \DateTime('-1 hour');
|
||||
$begin = new \DateTime('-10 hour');
|
||||
$activity = new Activity();
|
||||
$project1 = new Project();
|
||||
$project2 = new Project();
|
||||
$activity->setProject($project1);
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet
|
||||
->setBegin($begin)
|
||||
->setEnd($end)
|
||||
->setActivity($activity)
|
||||
->setProject($project2)
|
||||
;
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
|
||||
|
||||
$this->buildViolation('Project mismatch, project specific activity and timesheet project are different.')
|
||||
->atPath('property.path.project')
|
||||
->setCode(TimesheetConstraint::ACTIVITY_PROJECT_MISMATCH_ERROR)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testDisabledValuesDuringStart()
|
||||
{
|
||||
$begin = new \DateTime('-10 hour');
|
||||
$customer = new Customer();
|
||||
$customer->setVisible(false);
|
||||
$activity = new Activity();
|
||||
$activity->setVisible(false);
|
||||
$project = new Project();
|
||||
$project->setVisible(false);
|
||||
$project->setCustomer($customer);
|
||||
$activity->setProject($project);
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet
|
||||
->setBegin($begin)
|
||||
->setActivity($activity)
|
||||
->setProject($project)
|
||||
;
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
|
||||
|
||||
$this->buildViolation('Cannot start a disabled activity.')
|
||||
->atPath('property.path.activity')
|
||||
->setCode(TimesheetConstraint::DISABLED_ACTIVITY_ERROR)
|
||||
->buildNextViolation('Cannot start a disabled project.')
|
||||
->atPath('property.path.project')
|
||||
->setCode(TimesheetConstraint::DISABLED_PROJECT_ERROR)
|
||||
->buildNextViolation('Cannot start a disabled customer.')
|
||||
->atPath('property.path.customer')
|
||||
->setCode(TimesheetConstraint::DISABLED_CUSTOMER_ERROR)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function getProjectStartEndTestData()
|
||||
{
|
||||
yield [new \DateTime(), new \DateTime(), [
|
||||
['begin', TimesheetConstraint::PROJECT_NOT_STARTED, 'The project has not started at that time.'],
|
||||
['end', TimesheetConstraint::PROJECT_NOT_STARTED, 'The project has not started at that time.'],
|
||||
]];
|
||||
|
||||
yield [new \DateTime('-9 hour'), new \DateTime('-2 hour'), [
|
||||
['begin', TimesheetConstraint::PROJECT_NOT_STARTED, 'The project has not started at that time.'],
|
||||
['end', TimesheetConstraint::PROJECT_ALREADY_ENDED, 'The project is finished at that time.'],
|
||||
]];
|
||||
|
||||
yield [new \DateTime('-19 hour'), new \DateTime('-12 hour'), [
|
||||
['begin', TimesheetConstraint::PROJECT_ALREADY_ENDED, 'The project is finished at that time.'],
|
||||
['end', TimesheetConstraint::PROJECT_ALREADY_ENDED, 'The project is finished at that time.'],
|
||||
]];
|
||||
|
||||
yield [new \DateTime('-19 hour'), new \DateTime('-2 hour'), [
|
||||
['end', TimesheetConstraint::PROJECT_ALREADY_ENDED, 'The project is finished at that time.'],
|
||||
]];
|
||||
|
||||
yield [new \DateTime('-9 hour'), new \DateTime(), [
|
||||
['begin', TimesheetConstraint::PROJECT_NOT_STARTED, 'The project has not started at that time.'],
|
||||
]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getProjectStartEndTestData
|
||||
*/
|
||||
public function testEndBeforeWithProjectStartAndEnd(\DateTime $start, \DateTime $end, array $violations)
|
||||
{
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setBegin(new \DateTime('-10 hour'));
|
||||
$timesheet->setEnd(new \DateTime('-1 hour'));
|
||||
|
||||
$customer = new Customer();
|
||||
$project = new Project();
|
||||
$project->setStart($start);
|
||||
$project->setEnd($end);
|
||||
$project->setCustomer($customer);
|
||||
|
||||
$timesheet->setProject($project);
|
||||
$timesheet->setActivity(new Activity());
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
|
||||
|
||||
/** @var ConstraintViolationAssertion $assertion */
|
||||
$assertion = null;
|
||||
foreach ($violations as $violation) {
|
||||
if (null === $assertion) {
|
||||
$assertion = $this->buildViolation($violation[2])
|
||||
->atPath('property.path.' . $violation[0])
|
||||
->setCode($violation[1])
|
||||
;
|
||||
} else {
|
||||
$assertion = $assertion->buildNextViolation($violation[2])
|
||||
->atPath('property.path.' . $violation[0])
|
||||
->setCode($violation[1])
|
||||
;
|
||||
}
|
||||
}
|
||||
$assertion->assertRaised();
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user