summaryrefslogtreecommitdiff
path: root/plugins/Timetrackingeditor
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/Timetrackingeditor')
-rw-r--r--plugins/Timetrackingeditor/Console/AllSubtaskTimeTrackingExportCommand.php29
-rw-r--r--plugins/Timetrackingeditor/Console/SubtaskTimeTrackingExportCommand.php35
-rw-r--r--plugins/Timetrackingeditor/Controller/SubtaskAjaxController.php45
-rw-r--r--plugins/Timetrackingeditor/Controller/SubtaskStatusController.php49
-rw-r--r--plugins/Timetrackingeditor/Controller/TimeTrackingEditorController.php377
-rw-r--r--plugins/Timetrackingeditor/Export/SubtaskTimeTrackingExport.php218
-rw-r--r--plugins/Timetrackingeditor/Filter/SubtaskIdFilter.php39
-rw-r--r--plugins/Timetrackingeditor/Filter/SubtaskTasksFilter.php46
-rw-r--r--plugins/Timetrackingeditor/Filter/SubtaskTitleFilter.php47
-rw-r--r--plugins/Timetrackingeditor/Formatter/SubtaskAutoCompleteFormatter.php39
-rw-r--r--plugins/Timetrackingeditor/Html.php182
-rw-r--r--plugins/Timetrackingeditor/Model/SubtaskTimeTrackingCreationModel.php53
-rw-r--r--plugins/Timetrackingeditor/Model/SubtaskTimeTrackingEditModel.php143
-rw-r--r--plugins/Timetrackingeditor/Model/SubtaskTimeTrackingModel.php133
-rw-r--r--plugins/Timetrackingeditor/Plugin.php91
-rw-r--r--plugins/Timetrackingeditor/README.md40
-rw-r--r--plugins/Timetrackingeditor/Schema/Mysql.php16
-rw-r--r--plugins/Timetrackingeditor/Schema/Postgres.php16
-rw-r--r--plugins/Timetrackingeditor/Schema/Sqlite.php16
-rw-r--r--plugins/Timetrackingeditor/Template/create.php41
-rw-r--r--plugins/Timetrackingeditor/Template/edit.php41
-rw-r--r--plugins/Timetrackingeditor/Template/menu.php23
-rw-r--r--plugins/Timetrackingeditor/Template/remove.php20
-rw-r--r--plugins/Timetrackingeditor/Template/start.php23
-rw-r--r--plugins/Timetrackingeditor/Template/stop.php23
-rw-r--r--plugins/Timetrackingeditor/Template/subtask/table.php89
-rw-r--r--plugins/Timetrackingeditor/Template/time_tracking_editor.php53
-rw-r--r--plugins/Timetrackingeditor/Test/PluginTest.php17
-rw-r--r--plugins/Timetrackingeditor/Test/TimetrackingeditorTest.php48
-rw-r--r--plugins/Timetrackingeditor/Validator/SubtaskTimeTrackingValidator.php73
-rw-r--r--plugins/Timetrackingeditor/assets/css/timetrackingeditor.css3
31 files changed, 2068 insertions, 0 deletions
diff --git a/plugins/Timetrackingeditor/Console/AllSubtaskTimeTrackingExportCommand.php b/plugins/Timetrackingeditor/Console/AllSubtaskTimeTrackingExportCommand.php
new file mode 100644
index 00000000..6a56aa22
--- /dev/null
+++ b/plugins/Timetrackingeditor/Console/AllSubtaskTimeTrackingExportCommand.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Kanboard\Plugin\Timetrackingeditor\Console;
+
+use Kanboard\Plugin\Timetrackingeditor\Html;
+use Kanboard\Model\SubtaskTimeTrackingModel;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Kanboard\Console\BaseCommand;
+
+class AllSubtaskTimeTrackingExportCommand extends BaseCommand
+{
+ protected function configure()
+ {
+ $this
+ ->setName('export:allsubtaskstimetracking')
+ ->setDescription('Subtasks Time Tracking CSV export for all events');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output)
+ {
+ $data = $this->subtaskTimeTrackingExport->exportAll();
+
+ if (is_array($data)) {
+ Html::output($data);
+ }
+ }
+}
diff --git a/plugins/Timetrackingeditor/Console/SubtaskTimeTrackingExportCommand.php b/plugins/Timetrackingeditor/Console/SubtaskTimeTrackingExportCommand.php
new file mode 100644
index 00000000..4f6e6384
--- /dev/null
+++ b/plugins/Timetrackingeditor/Console/SubtaskTimeTrackingExportCommand.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Kanboard\Console;
+
+use Kanboard\Core\Csv;
+use Kanboard\Model\SubtaskTimeTrackingModel;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class SubtaskTimeTrackingExportCommand extends BaseCommand
+{
+ protected function configure()
+ {
+ $this
+ ->setName('export:subtaskstimetracking')
+ ->setDescription('Subtasks Time Tracking CSV export')
+ ->addArgument('project_id', InputArgument::REQUIRED, 'Project id')
+ ->addArgument('start_date', InputArgument::REQUIRED, 'Start date (YYYY-MM-DD)')
+ ->addArgument('end_date', InputArgument::REQUIRED, 'End date (YYYY-MM-DD)');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output)
+ {
+ $data = $this->subtaskTimeTrackingExport->export(
+ $input->getArgument('project_id'),
+ $input->getArgument('start_date'),
+ $input->getArgument('end_date')
+ );
+
+ if (is_array($data)) {
+ Html::output($data);
+ }
+ }
+}
diff --git a/plugins/Timetrackingeditor/Controller/SubtaskAjaxController.php b/plugins/Timetrackingeditor/Controller/SubtaskAjaxController.php
new file mode 100644
index 00000000..4871bfe0
--- /dev/null
+++ b/plugins/Timetrackingeditor/Controller/SubtaskAjaxController.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Kanboard\Plugin\Timetrackingeditor\Controller;
+
+use Kanboard\Controller\BaseController;
+use Kanboard\Core\Filter\QueryBuilder;
+use Kanboard\Model\SubtaskModel;
+use Kanboard\Plugin\Timetrackingeditor\Filter\SubtaskTasksFilter;
+use Kanboard\Plugin\Timetrackingeditor\Filter\SubtaskIdFilter;
+use Kanboard\Plugin\Timetrackingeditor\Filter\SubtaskTitleFilter;
+use Kanboard\Plugin\Timetrackingeditor\Formatter\SubtaskAutoCompleteFormatter;
+
+/**
+ * Task Ajax Controller
+ *
+ * @package Kanboard\Plugin\Timetrackingeditor\Controller
+ * @author Thomas Stinner
+ */
+class SubtaskAjaxController extends BaseController
+{
+ /**
+ * Task auto-completion (Ajax)
+ *
+ * @access public
+ */
+ public function autocomplete()
+ {
+ $search = $this->request->getStringParam('term');
+ $task_id = $this->request->getIntegerParam('task_id');
+
+ $subtaskQuery = new QueryBuilder();
+ $subtaskQuery->withQuery($this->db
+ ->table(SubtaskModel::TABLE)
+ ->eq('task_id', $task_id)
+ ->columns(SubtaskModel::TABLE.'*'));
+
+ if (ctype_digit($search)) {
+ $subtaskQuery->withFilter(new SubtaskIdFilter($search));
+ } else {
+ $subtaskQuery->withFilter(new SubtaskTitleFilter($search));
+ }
+
+ $this->response->json($subtaskQuery->format(new SubtaskAutoCompleteFormatter($this->container)));
+ }
+}
diff --git a/plugins/Timetrackingeditor/Controller/SubtaskStatusController.php b/plugins/Timetrackingeditor/Controller/SubtaskStatusController.php
new file mode 100644
index 00000000..c27881a1
--- /dev/null
+++ b/plugins/Timetrackingeditor/Controller/SubtaskStatusController.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Kanboard\Plugin\Timetrackingeditor\Controller;
+
+use Kanboard\Model\SubtaskModel;
+
+/**
+ * SubtaskStatusController.
+ *
+ * @author Thomas Stinner
+ */
+class SubtaskStatusController extends \Kanboard\Controller\SubtaskStatusController
+{
+ /**
+ * Change status to the next status: Toto -> In Progress -> Done.
+ */
+ public function change()
+ {
+ $task = $this->getTask();
+ $subtask = $this->getSubtask();
+
+ if ($subtask['status'] == SubtaskModel::STATUS_DONE) {
+ $status = SubtaskModel::STATUS_TODO;
+ } else {
+ $status = SubtaskModel::STATUS_DONE;
+ }
+ $subtask['status'] = $status;
+ $this->subtaskModel->update($subtask);
+
+ if ($this->request->getIntegerParam('refresh-table') === 0) {
+
+ $html = $this->helper->subtask->toggleStatus($subtask, $task['project_id']);
+ } else {
+ $html = $this->renderTable($task);
+ }
+
+ $this->response->html($html);
+ }
+
+ protected function renderTable(array $task)
+ {
+ return $this->template->render('subtask/table', array(
+ 'task' => $task,
+ 'subtasks' => $this->subtaskModel->getAll($task['id']),
+ 'editable' => true,
+ ));
+ }
+}
+?>
diff --git a/plugins/Timetrackingeditor/Controller/TimeTrackingEditorController.php b/plugins/Timetrackingeditor/Controller/TimeTrackingEditorController.php
new file mode 100644
index 00000000..815074f2
--- /dev/null
+++ b/plugins/Timetrackingeditor/Controller/TimeTrackingEditorController.php
@@ -0,0 +1,377 @@
+<?php
+
+namespace Kanboard\Plugin\Timetrackingeditor\Controller;
+use Kanboard\Controller\BaseController;
+use Kanboard\Controller\SubtaskStatusController;
+use Kanboard\Model\SubtaskTimeTrackingModel;
+use Kanboard\Plugin\Timetrackingeditor\Model\SubtaskTimeTrackingEditModel;
+use Kanboard\Plugin\Timetrackingeditor\Model\SubtaskTimeTrackingCreationModel;
+use Kanboard\Plugin\Timetrackingeditor\Validator\SubtaskTimeTrackingValidator;
+
+/**
+ * Column Controller
+ *
+ * @package Kanboard\Plugin\Timetrackingeditor\Controller
+ * @author Frederic Guillot
+ */
+class TimeTrackingEditorController extends BaseController
+{
+
+/**
+ * Show Form to start the timer
+ * @access public
+ * @param array $values
+ * @param arry $errors
+ */
+
+ public function start(array $values = array(), array $errors = array())
+ {
+ $project = $this->getProject();
+
+ if (empty($values)) {
+ $values = array('project_id' => $project['id'],
+ 'task_id' => $this->request->getIntegerParam('task_id'),
+ 'subtask_id' => $this->request->getIntegerParam('subtask_id')
+ );
+ }
+
+ $values['subtask'] = $this->subtaskModel->getById($values['subtask_id']);
+
+ $this->response->html($this->template->render('Timetrackingeditor:start', array(
+ 'values' => $values,
+ 'errors' => $errors,
+ 'project' => $project,
+ 'title' => t('Start a new timer')
+ )));
+ }
+
+ /**
+ * Show Form to stop the timer
+ * @access public
+ * @param array $values
+ * @param arry $errors
+ */
+
+ public function stop(array $values = array(), array $errors = array())
+ {
+ $project = $this->getProject();
+
+ if (empty($values)) {
+ $values = array('project_id' => $project['id'],
+ 'task_id' => $this->request->getIntegerParam('task_id'),
+ 'subtask_id' => $this->request->getIntegerParam('subtask_id')
+ );
+ }
+
+ $values['subtask'] = $this->subtaskModel->getById($values['subtask_id']);
+
+ $timetracking = $this->subtaskTimeTrackingEditModel
+ ->getOpenTimer(
+ $this->userSession->getId(),
+ $values['subtask_id']
+ );
+
+ $values['comment'] = $timetracking["comment"];
+ $values['is_billable'] = $timetracking['is_billable'];
+
+ $this->response->html($this->template->render('Timetrackingeditor:stop', array(
+ 'values' => $values,
+ 'errors' => $errors,
+ 'project' => $project,
+ 'title' => t('Stop a timer')
+ )));
+ }
+
+
+/**
+ * Start the timer and save comment and is_billable
+ * @access public
+ *
+ */
+
+ public function startsave()
+ {
+ $values = $this->request->getValues();
+ $project = $this->getProject();
+ $task = $this->getTask();
+
+ if (!$this->subtaskTimeTrackingModel->logStartTimeExtended(
+ $values['subtask_id'],
+ $this->userSession->getId(),
+ $values['comment'],
+ $values['is_billable'] ?: 0)) {
+ // TODO: Best way to display the errors?
+ $this->flash->failure("Another Timer is already running");
+ return false;
+ }
+
+ $this->subtaskStatusModel->toggleStatus($values['subtask_id']);
+
+ return $this->response->redirect($this->helper->url->to('SubtaskStatusController', 'change', array(
+ 'refresh-table' => 1,
+ 'project_id' => $project['id'],
+ 'task_id' => $task['id'],
+ 'subtask_id' => $values['subtask_id']
+ )), true);
+ }
+
+ /**
+ * Stop the timer and save comment and is_billable
+ *
+ * @access public
+ */
+ public function stopsave()
+ {
+
+ $values = $this->request->getValues();
+ $project = $this->getProject();
+ $task = $this->getTask();
+
+ $this->subtaskTimeTrackingModel->logEndTimeExtended(
+ $values['subtask_id'],
+ $this->userSession->getId(),
+ $values['comment'],
+ $values['is_billable'] ?: 0);
+
+ $this->subtaskStatusModel->toggleStatus($values['subtask_id']);
+
+ return $this->response->redirect($this->helper->url->to('SubtaskStatusController', 'change', array(
+ 'refresh-table' => 1,
+ 'project_id' => $project['id'],
+ 'task_id' => $task['id'],
+ 'subtask_id' => $values['subtask_id']
+ )), true);
+ }
+ /**
+ * Show Form to create new entry
+ * @access public
+ * @param array $values
+ * @param array $errors
+ */
+ public function create(array $values = array(), array $errors = array())
+ {
+ $project = $this->getProject();
+
+ if (empty($values)) {
+ $values = array('project_id' => $project['id'],
+ 'task_id' => $this->request->getIntegerParam('task_id')
+ );
+ }
+
+ if ($this->request->getIntegerParam('subtask_id')) {
+ $values['opposite_subtask_id'] = $this->request->getIntegerParam('subtask_id');
+ $subtask = $this->subtaskModel->getById($values['opposite_subtask_id']);
+
+ $values['subtask'] = $subtask['title'];
+ $autofocus = "time_spent";
+ } else {
+ $autofocus = "subtask";
+ }
+
+
+ $this->response->html($this->template->render('Timetrackingeditor:create', array(
+ 'values' => $values,
+ 'errors' => $errors,
+ 'project' => $project,
+ 'autofocus' => $autofocus,
+ 'title' => t('Add new time tracking event')
+ )));
+ }
+
+ /**
+ * Edit an existing entry
+ *
+ * @access public
+ * @param array $values
+ * @param array $errors
+ */
+ public function edit(array $values = array(), array $errors = array())
+ {
+ $project = $this->getProject();
+
+ if (empty($values)) {
+ $values = array('project_id' => $project['id'],
+ 'task_id' => $this->request->getIntegerParam('task_id'),
+ 'subtask_id' => $this->request->getIntegerParam('subtask_id'),
+ 'id' => $this->request->getIntegerParam('id')
+ );
+ }
+
+ $values = $this->subtaskTimeTrackingEditModel->getById($this->request->getIntegerParam('id'));
+
+ $values = $this->dateParser->format($values, array('start'), $this->dateParser->getUserDateFormat());
+ $values['subtask'] = $values['subtask_title'];
+ $values['opposite_subtask_id'] = $values['subtask_id'];
+
+ $this->response->html($this->template->render('Timetrackingeditor:edit', array(
+ 'values' => $values,
+ 'errors' => $errors,
+ 'project' => $project,
+ 'title' => t('Edit a time tracking event')
+ )));
+ }
+
+ /**
+ * Update a time tracking entry
+ *
+ * @access public
+ * @param array $values
+ * @param array $errors
+ */
+ public function update(array $values = array(), array $errors = array())
+ {
+ $project = $this->getProject();
+ $values = $this->request->getValues();
+ $oldtimetracking = $this->subtaskTimeTrackingModel->getById($values['id']);
+
+ if (!isset($values['is_billable'])) {
+ $values["is_billable"] = 0;
+ }
+
+ list($valid, $errors) = $this->subtaskTimeTrackingValidator->validateModification($values);
+
+ if ($valid && $this->subtaskTimeTrackingEditModel->update($values)) {
+ $this->flash->success(t('Timetracking entry updated successfully.'));
+ $this->updateTimespent($values['task_id'], $oldtimetracking['subtask_id'], $oldtimetracking['time_spent'] * -1);
+ $this->updateTimespent($values['task_id'], $values['opposite_subtask_id'], $values['time_spent']);
+
+ if ($oldtimetracking['is_billable'] == 1) {
+ $this->updateTimebillable($values['task_id'], $oldtimetracking['opposite_subtask_id'], $oldtimetracking['time_spent'] * -1);
+ }
+ if ($values['is_billable'] == 1) {
+ $this->updateTimebillable($values['task_id'], $values['opposite_subtask_id'], $values['time_spent']);
+ }
+ return $this->afterSave($project, $values);
+ }
+
+ $this->flash->failure(t('Unable to update your time tracking entry.'));
+ return $this->edit($values, $errors);
+
+ }
+
+
+ /**
+ * Save a newly created time tracking entry
+ * @access public
+ * @param array $values
+ * @param array $errors
+ */
+ public function save(array $values = array(), array $errors = array())
+ {
+ $project = $this->getProject();
+ $values = $this->request->getValues();
+
+ list($valid, $errors) = $this->subtaskTimeTrackingValidator->validateCreation($values);
+
+ if ($valid && $this->subtaskTimeTrackingCreationModel->create($values)) {
+ $this->updateTimespent($values['task_id'], $values['opposite_subtask_id'], $values['time_spent']);
+ if (isset($values['is_billable']) && $values['is_billable'] == 1) {
+ $this->updateTimebillable($values['task_id'], $values['opposite_subtask_id'], $values['time_spent']);
+ }
+ $this->flash->success(t('Timetracking entry added successfully.'));
+
+ return $this->afterSave($project, $values);
+ }
+
+ $this->flash->failure(t('Unable to create your time tracking entry.'));
+ return $this->create($values, $errors);
+
+ }
+
+ /**
+ * Confirmation dialog before removing an entry
+ *
+ * @access public
+ */
+ public function confirm()
+ {
+
+ $id = $this->request->getIntegerParam('id');
+
+ $timetracking = $this->subtaskTimeTrackingEditModel->getById($id);
+
+ $this->response->html($this->template->render('timetrackingeditor:remove', array(
+ 'timetracking' => $timetracking,
+ )));
+ }
+
+ /**
+ * Remove an entry
+ *
+ * @access public
+ */
+ public function remove()
+ {
+ $this->checkCSRFParam();
+ $id = $this->request->getIntegerParam('id');
+ $timetracking = $this->subtaskTimeTrackingEditModel->getById($id);
+
+ if ($this->subtaskTimeTrackingEditModel->remove($id)) {
+ $this->updateTimespent($timetracking['task_id'], $timetracking['subtask_id'], $timetracking['time_spent'] * -1);
+ if ($timetracking['is_billable'] == 1) {
+ $this->updateTimebillable($timetracking['task_id'], $timetracking['subtask_id'], $timetracking['time_spent'] * -1);
+ }
+ $this->flash->success(t('Entry removed successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to remove this entry.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('TaskViewController', 'timetracking', array('project_id' => $timetracking['project_id'], 'task_id' => $timetracking['task_id'])), true);
+ }
+
+ /**
+ * update time spent for the task
+ *
+ * @access private
+ * @param int $task_id
+ * @param int $subtask_id
+ * @return bool
+ */
+
+ private function updateTimespent($task_id, $subtask_id, $time_spent)
+ {
+ $this->subtaskTimeTrackingModel->updateSubtaskTimeSpent($subtask_id, $time_spent);
+ return $this->subtaskTimeTrackingModel->updateTaskTimeTracking($task_id);
+
+ }
+
+/**
+ * update time billable for the task
+ *
+ * @access private
+ * @param int $task_id
+ * @param int $subtask_id
+ * @return bool
+ */
+ private function updateTimebillable($task_id, $subtask_id, $time_billable)
+ {
+ $this->subtaskTimeTrackingModel->updateSubtaskTimeBillable($subtask_id, $time_billable);
+ return $this->subtaskTimeTrackingModel->updateTaskTimeTracking($task_id);
+ }
+
+
+ /**
+ * Present another, empty form if add_another is activated
+ *
+ * @access private
+ * @param array $project
+ * @param array $values
+ */
+ private function afterSave(array $project, array &$values)
+ {
+ if (isset($values['add_another']) && $values['add_another'] == 1) {
+ return $this->create(array(
+ 'project_id' => $this->getProject()['id'],
+ 'subtask' => $values['subtask'],
+ 'opposite_subtask_id' => $values['opposite_subtask_id'],
+ 'task_id' => $values['task_id'],
+ 'start' => $values['start'],
+ 'is_billable' => $values['is_billable'],
+ 'add_another' => 1,
+ ));
+ }
+
+ return $this->response->redirect($this->helper->url->to('TaskViewController', 'timetracking', array('project_id' => $project['id'], 'task_id' => $values['task_id'])), true);
+ }
+
+}
diff --git a/plugins/Timetrackingeditor/Export/SubtaskTimeTrackingExport.php b/plugins/Timetrackingeditor/Export/SubtaskTimeTrackingExport.php
new file mode 100644
index 00000000..c9fc1071
--- /dev/null
+++ b/plugins/Timetrackingeditor/Export/SubtaskTimeTrackingExport.php
@@ -0,0 +1,218 @@
+<?php
+
+namespace Kanboard\Plugin\Timetrackingeditor\Export;
+
+use Kanboard\Core\Base;
+use Kanboard\Model\TaskModel;
+use Kanboard\Model\SubtaskModel;
+use Kanboard\Model\UserModel;
+use Kanboard\Model\SubtaskTimeTrackingModel;
+use Kanboard\Model\ProjectModel;
+
+/**
+ * SubtaskTimeTracking Export
+ *
+ * @package export
+ * @author Thomas Stinner
+ */
+class SubtaskTimeTrackingExport extends Base
+{
+ /**
+ * Fetch subtasks time tracking and return the prepared CSV
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param mixed $from Start date (timestamp or user formatted date)
+ * @param mixed $to End date (timestamp or user formatted date)
+ * @return array
+ */
+ public function export($project_id, $from, $to)
+ {
+ $subtaskstt = $this->getSubtasksTimeTracking($project_id, $from, $to);
+ $results = array($this->getColumns());
+
+ foreach ($subtaskstt as $subtasktt) {
+ $results[] = $this->format($subtasktt);
+ }
+
+ return $results;
+ }
+
+ public function exportAll()
+ {
+ $subtaskstt = $this->getAllSubtasksTimeTracking();
+ $results = array($this->getFormats());
+ $results[] = $this->getColumns();
+
+ foreach ($subtaskstt as $subtasktt) {
+ $results[] = $this->format($subtasktt);
+ }
+
+ return $results;
+ }
+
+ /**
+ * Get column titles
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getColumns()
+ {
+ return array(
+ e('TimeTracking Id'),
+ e('User Id'),
+ e('Subtask Id'),
+ e('start'),
+ e('end'),
+ e('Time Spent'),
+ e('Is Billable?'),
+ e('Comment'),
+ e('Task Id'),
+ e('Task Title'),
+ e('Subtask Title'),
+ e('Project Id'),
+ e('Project Name'),
+ e('Color Id'),
+ e('Username'),
+ e('User Fullname'),
+ );
+ }
+
+ /**
+ * Get Format of the getColumns
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getFormats()
+ {
+ return array(
+ 'num',
+ 'num',
+ 'num',
+ 'date',
+ 'date',
+ 'dec',
+ 'bool',
+ 'text',
+ 'num',
+ 'text',
+ 'text',
+ 'num',
+ 'text',
+ 'num',
+ 'text',
+ 'text'
+ );
+ }
+
+ /**
+ * Format the output of a subtask array
+ *
+ * @access public
+ * @param array $subtask Subtask properties
+ * @return array
+ */
+ public function format(array $subtasktt)
+ {
+ $values = array();
+ $values[] = $subtasktt['id'];
+ $values[] = $subtasktt['user_id'];
+ $values[] = $subtasktt['subtask_id'];
+ $values[] = $this->helper->dt->date($subtasktt['start']);
+ $values[] = $this->helper->dt->date($subtasktt['end']);
+ $values[] = str_replace(".",",",$subtasktt['time_spent']);
+ $values[] = $subtasktt['is_billable'];
+ $values[] = $this->helper->text->markdown($subtasktt['comment']);
+ $values[] = $subtasktt['task_id'];
+ $values[] = $subtasktt['task_title'];
+ $values[] = $subtasktt['subtask_title'];
+ $values[] = $subtasktt['project_id'];
+ $values[] = $subtasktt['project_name'];
+ $values[] = $subtasktt['color_id'];
+ $values[] = $subtasktt['username'];
+ $values[] = $subtasktt['user_fullname'];
+ return $values;
+ }
+
+ /**
+ * Get all time tracking events for a given project
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param mixed $from Start date (timestamp or user formatted date)
+ * @param mixed $to End date (timestamp or user formatted date)
+ * @return array
+ */
+ public function getSubtasksTimeTracking($project_id, $from, $to)
+ {
+ if (! is_numeric($from)) {
+ $from = $this->dateParser->removeTimeFromTimestamp($this->dateParser->getTimestamp($from));
+ }
+
+ if (! is_numeric($to)) {
+ $to = $this->dateParser->removeTimeFromTimestamp(strtotime('+1 day', $this->dateParser->getTimestamp($to)));
+ }
+
+ return $this->db->table(SubtaskTimeTrackingModel::TABLE)
+ ->eq('project_id', $project_id)
+ ->columns(
+ SubtaskTimeTrackingModel::TABLE.'.id',
+ SubtaskTimeTrackingModel::TABLE.'.user_id',
+ SubtaskTimeTrackingModel::TABLE.'.subtask_id',
+ SubtaskTimeTrackingModel::TABLE.'.start',
+ SubtaskTimeTrackingModel::TABLE.'.end',
+ SubtaskTimeTrackingModel::TABLE.'.time_spent',
+ SubtaskTimeTrackingModel::TABLE.'.is_billable',
+ SubtaskTimeTrackingModel::TABLE.'.comment',
+ SubtaskModel::TABLE.'.task_id',
+ SubtaskModel::TABLE.'.title AS subtask_title',
+ TaskModel::TABLE.'.project_id',
+ ProjectModel::TABLE.'.name AS project_name',
+ TaskModel::TABLE.'.title AS task_title',
+ TaskModel::TABLE.'.color_id',
+ UserModel::TABLE.'.username',
+ UserModel::TABLE.'.name AS user_fullname'
+ )
+ ->join(SubtaskModel::TABLE, 'id', 'subtask_id')
+ ->join(TaskModel::TABLE, 'id', 'task_id', SubtaskModel::TABLE)
+ ->join(UserModel::TABLE, 'id', 'user_id', SubtaskTimeTrackingModel::TABLE)
+ ->join(ProjectModel::TABLE, 'id', 'project_id', TaskModel::TABLE)
+ ->gte(SubtaskTimeTrackingModel::TABLE.'.start', $from)
+ ->lte(SubtaskTimeTrackingModel::TABLE.'.start', $to)
+ ->eq(TaskModel::TABLE.'.project_id', $project_id)
+ ->findAll();
+ }
+
+ public function getAllSubtasksTimeTracking()
+ {
+
+ return $this->db->table(SubtaskTimeTrackingModel::TABLE)
+ ->columns(
+ SubtaskTimeTrackingModel::TABLE.'.id',
+ SubtaskTimeTrackingModel::TABLE.'.user_id',
+ SubtaskTimeTrackingModel::TABLE.'.subtask_id',
+ SubtaskTimeTrackingModel::TABLE.'.start',
+ SubtaskTimeTrackingModel::TABLE.'.end',
+ SubtaskTimeTrackingModel::TABLE.'.time_spent',
+ SubtaskTimeTrackingModel::TABLE.'.is_billable',
+ SubtaskTimeTrackingModel::TABLE.'.comment',
+ SubtaskModel::TABLE.'.task_id',
+ SubtaskModel::TABLE.'.title AS subtask_title',
+ TaskModel::TABLE.'.project_id',
+ ProjectModel::TABLE.'.name AS project_name',
+ TaskModel::TABLE.'.title AS task_title',
+ TaskModel::TABLE.'.color_id',
+ UserModel::TABLE.'.username',
+ UserModel::TABLE.'.name AS user_fullname'
+ )
+ ->join(SubtaskModel::TABLE, 'id', 'subtask_id')
+ ->join(TaskModel::TABLE, 'id', 'task_id', SubtaskModel::TABLE)
+ ->join(UserModel::TABLE, 'id', 'user_id', SubtaskTimeTrackingModel::TABLE)
+ ->join(ProjectModel::TABLE, 'id', 'project_id', TaskModel::TABLE)
+ ->findAll();
+
+
+ }
+}
diff --git a/plugins/Timetrackingeditor/Filter/SubtaskIdFilter.php b/plugins/Timetrackingeditor/Filter/SubtaskIdFilter.php
new file mode 100644
index 00000000..79779446
--- /dev/null
+++ b/plugins/Timetrackingeditor/Filter/SubtaskIdFilter.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Kanboard\Plugin\Timetrackingeditor\Filter;
+
+use Kanboard\Core\Filter\FilterInterface;
+use Kanboard\Filter\BaseFilter;
+use Kanboard\Model\SubtaskModel;
+
+/**
+ * Filter subtasks by id
+ *
+ * @package filter
+ * @author Thomas Stinner
+ */
+class SubtaskIdFilter extends BaseFilter implements FilterInterface
+{
+ /**
+ * Get search attribute
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getAttributes()
+ {
+ return array('id');
+ }
+
+ /**
+ * Apply filter
+ *
+ * @access public
+ * @return FilterInterface
+ */
+ public function apply()
+ {
+ $this->query->eq(SubtaskModel::TABLE.'.id', $this->value);
+ return $this;
+ }
+}
diff --git a/plugins/Timetrackingeditor/Filter/SubtaskTasksFilter.php b/plugins/Timetrackingeditor/Filter/SubtaskTasksFilter.php
new file mode 100644
index 00000000..d96a1a34
--- /dev/null
+++ b/plugins/Timetrackingeditor/Filter/SubtaskTasksFilter.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Kanboard\Plugin\Timetrackingeditor\Filter;
+
+
+use Kanboard\Core\Filter\FilterInterface;
+use Kanboard\Filter\BaseFilter;
+use Kanboard\Model\ProjectModel;
+use Kanboard\Model\SubtaskModel;
+
+/**
+ * Filter subtasks by task
+ *
+ * @package filter
+ * @author Thomas Stinner
+ */
+class SubtaskTasksFilter extends BaseFilter implements FilterInterface
+{
+ /**
+ * Get search attribute
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getAttributes()
+ {
+ return array('task');
+ }
+
+ /**
+ * Apply filter
+ *
+ * @access public
+ * @return FilterInterface
+ */
+ public function apply()
+ {
+ if (is_int($this->value) || ctype_digit($this->value)) {
+ $this->query->eq(SubtaskModel::TABLE.'.task_id', $this->value);
+ } else {
+ $this->query->ilike(TaskModel::TABLE.'.name', $this->value);
+ }
+
+ return $this;
+ }
+}
diff --git a/plugins/Timetrackingeditor/Filter/SubtaskTitleFilter.php b/plugins/Timetrackingeditor/Filter/SubtaskTitleFilter.php
new file mode 100644
index 00000000..5be456f1
--- /dev/null
+++ b/plugins/Timetrackingeditor/Filter/SubtaskTitleFilter.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Kanboard\Plugin\TimetrackingEditor\Filter;
+
+use Kanboard\Core\Filter\FilterInterface;
+use Kanboard\Filter\BaseFilter;
+use Kanboard\Model\SubtaskModel;
+
+/**
+ * Filter Subtasks by title
+ *
+ * @package filter
+ * @author Thomas Stinner
+ */
+class SubtaskTitleFilter extends BaseFilter implements FilterInterface
+{
+ /**
+ * Get search attribute
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getAttributes()
+ {
+ return array('title');
+ }
+
+ /**
+ * Apply filter
+ *
+ * @access public
+ * @return FilterInterface
+ */
+ public function apply()
+ {
+ if (ctype_digit($this->value) || (strlen($this->value) > 1 && $this->value{0} === '#' && ctype_digit(substr($this->value, 1)))) {
+ $this->query->beginOr();
+ $this->query->eq(SubtaskModel::TABLE.'.id', str_replace('#', '', $this->value));
+ $this->query->ilike(SubtaskModel::TABLE.'.title', '%'.$this->value.'%');
+ $this->query->closeOr();
+ } else {
+ $this->query->ilike(SubtaskModel::TABLE.'.title', '%'.$this->value.'%');
+ }
+
+ return $this;
+ }
+}
diff --git a/plugins/Timetrackingeditor/Formatter/SubtaskAutoCompleteFormatter.php b/plugins/Timetrackingeditor/Formatter/SubtaskAutoCompleteFormatter.php
new file mode 100644
index 00000000..724a146e
--- /dev/null
+++ b/plugins/Timetrackingeditor/Formatter/SubtaskAutoCompleteFormatter.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Kanboard\Plugin\Timetrackingeditor\Formatter;
+
+use Kanboard\Core\Filter\FormatterInterface;
+use Kanboard\Model\ProjectModel;
+use Kanboard\Model\SubtaskModel;
+use Kanboard\Model\TaskModel;
+use Kanboard\Formatter\BaseFormatter;
+
+/**
+ * Subtask AutoComplete Formatter
+ *
+ * @package formatter
+ * @author Thomas Stinner
+ */
+class SubtaskAutoCompleteFormatter extends BaseFormatter implements FormatterInterface
+{
+ /**
+ * Apply formatter
+ *
+ * @access public
+ * @return array
+ */
+ public function format()
+ {
+ $subtasks = $this->query->columns(
+ SubtaskModel::TABLE.'.id',
+ SubtaskModel::TABLE.'.title'
+ )->asc(SubtaskModel::TABLE.'.id')->findAll();
+
+ foreach ($subtasks as &$subtask) {
+ $subtask['value'] = $subtask['title'];
+ $subtask['label'] = ' > #'.$subtask['id'].' '.$subtask['title'];
+ }
+
+ return $subtasks;
+ }
+}
diff --git a/plugins/Timetrackingeditor/Html.php b/plugins/Timetrackingeditor/Html.php
new file mode 100644
index 00000000..eb3e1fa5
--- /dev/null
+++ b/plugins/Timetrackingeditor/Html.php
@@ -0,0 +1,182 @@
+<?php
+
+namespace Kanboard\Plugin\Timetrackingeditor;
+
+use SplFileObject;
+
+/**
+ * HTML Writer
+ *
+ * Allows exporting Data as HTML file.
+ * In contrast to CSV this allows clean interpration of HTML Tags by Excel
+ *
+ * @author Thomas Stinner
+ */
+class Html
+{
+ /**
+ * CSV/SQL columns
+ *
+ * @access private
+ * @var array
+ */
+ private $columns = array();
+
+ /**
+ * Constructor
+ *
+ * @access public
+ */
+ public function __construct()
+ {
+ }
+
+ /**
+ * Check boolean field value
+ *
+ * @static
+ * @access public
+ * @param mixed $value
+ * @return int
+ */
+ public static function getBooleanValue($value)
+ {
+ if (! empty($value)) {
+ $value = trim(strtolower($value));
+ return $value === '1' || $value{0} === 't' || $value{0} === 'y' ? 1 : 0;
+ }
+
+ return 0;
+ }
+
+ /**
+ * Output CSV file to standard output
+ *
+ * @static
+ * @access public
+ * @param array $rows
+ */
+ public static function output(array $rows)
+ {
+ $html= new static;
+ $html->write('php://output', $rows);
+ }
+
+ /**
+ * Define column mapping between CSV and SQL columns
+ *
+ * @access public
+ * @param array $columns
+ * @return Csv
+ */
+ public function setColumnMapping(array $columns)
+ {
+ $this->columns = $columns;
+ return $this;
+ }
+
+ /**
+ * Write HTML file
+ *
+ * @access public
+ * @param string $filename
+ * @param array $rows
+ * @return Html
+ */
+ public function write($filename, array $rows)
+ {
+
+ $fp = fopen($filename, 'w');
+
+ if (is_resource($fp)) {
+ $types = array_shift($rows);
+
+ $this->writeHeader($fp);
+ foreach ($rows as $row) {
+ $this->writeRow($fp, $types, $row);
+ }
+ $this->writeFooter($fp);
+ fclose($fp);
+ }
+
+ return $this;
+ }
+
+ /**
+ * write a HTML header
+ *
+ * @param $fp filepointer
+ */
+ private function writeHeader($fp)
+ {
+ fwrite($fp,"<HTML><HEAD><STYLE>\n");
+ fwrite($fp,"b,p {mso-data-placement: same-cell; }\n");
+ fwrite($fp,".num { mso-number-format:General; }\n");
+ fwrite($fp,".dec { mso-number-format: 0,00; }\n");
+ fwrite($fp,".text { mso-number-format:\"\\@\"; }\n");
+ fwrite($fp,".date { mso-number-format:\"Short Date\"; }\n");
+ fwrite($fp,"</STYLE></HEAD>\n");
+ fwrite($fp,"<BODY><TABLE>");
+
+ }
+
+ /**
+ * write a single row
+ *
+ * @param fp filepointer
+ * @param $row row
+ */
+ private function writeRow($fp, array $types, array $row)
+ {
+ fwrite($fp,"<tr>");
+ foreach ($row as $key => $value) {
+ fwrite($fp,"<td class='".$types[$key]."'>".$value."</td>");
+ }
+ fwrite($fp,"</tr>\n");
+ }
+
+ /**
+ * write a HTML footer
+ */
+ private function writeFooter($fp)
+ {
+ fwrite($fp,"</TABLE></BODY></HTML>");
+ }
+
+ /**
+ * Associate columns header with row values
+ *
+ * @access private
+ * @param array $row
+ * @return array
+ */
+ private function associateColumns(array $row)
+ {
+ $line = array();
+ $index = 0;
+
+ foreach ($this->columns as $sql_name => $csv_name) {
+ if (isset($row[$index])) {
+ $line[$sql_name] = $row[$index];
+ } else {
+ $line[$sql_name] = '';
+ }
+
+ $index++;
+ }
+
+ return $line;
+ }
+
+ /**
+ * Filter empty rows
+ *
+ * @access private
+ * @param array $row
+ * @return array
+ */
+ private function filterRow(array $row)
+ {
+ return array_filter($row);
+ }
+}
diff --git a/plugins/Timetrackingeditor/Model/SubtaskTimeTrackingCreationModel.php b/plugins/Timetrackingeditor/Model/SubtaskTimeTrackingCreationModel.php
new file mode 100644
index 00000000..806d9345
--- /dev/null
+++ b/plugins/Timetrackingeditor/Model/SubtaskTimeTrackingCreationModel.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Kanboard\Plugin\Timetrackingeditor\Model;
+
+use Kanboard\Core\Base;
+use Kanboard\Event\TaskEvent;
+use Kanboard\Model\SubtaskTimeTrackingModel;
+
+/**
+ * Task Creation
+ *
+ * @package Kanboard\Plugin\Timetrackingeditor\Model
+ * @author Thomas Stinner
+ */
+class SubtaskTimeTrackingCreationModel extends Base
+{
+ /**
+ * Create a time tracking event
+ *
+ * @access public
+ * @param array $values Form values
+ * @return integer
+ */
+ public function create(array $values)
+ {
+
+ $this->prepare($values);
+ $subtrackingid = $this->db->table(SubtaskTimeTrackingModel::TABLE)->persist($values);
+
+ return (int) $subtrackingid;
+ }
+
+ /**
+ * Prepare data
+ *
+ * @access public
+ * @param array $values Form values
+ */
+ public function prepare(array &$values)
+ {
+ if ($this->userSession->isLogged()) {
+ $values['user_id'] = $this->userSession->getId();
+ }
+
+ $values["subtask_id"] = $values["opposite_subtask_id"];
+
+ $this->helper->model->removeFields($values, array('project_id', 'task_id', 'opposite_subtask_id', 'subtask', 'add_another'));
+
+ // Calculate end time
+ $values = $this->dateParser->convert($values, array('start'), true);
+ $values["end"] = $values["start"] + ($values['time_spent']*60*60);
+ }
+}
diff --git a/plugins/Timetrackingeditor/Model/SubtaskTimeTrackingEditModel.php b/plugins/Timetrackingeditor/Model/SubtaskTimeTrackingEditModel.php
new file mode 100644
index 00000000..a6ee24dc
--- /dev/null
+++ b/plugins/Timetrackingeditor/Model/SubtaskTimeTrackingEditModel.php
@@ -0,0 +1,143 @@
+<?php
+
+namespace Kanboard\Plugin\Timetrackingeditor\Model;
+
+use Kanboard\Core\Base;
+use Kanboard\Model\SubtaskModel;
+use Kanboard\Model\TaskModel;
+
+/**
+ * Task Creation
+ *
+ * @package Kanboard\Plugin\Timetrackingeditor\Model
+ * @author Thomas Stinner
+ */
+class SubtaskTimeTrackingEditModel extends Base
+{
+
+ /**
+ * GetOpenTimer
+ * Returns the Open (started but not finished) Time Tracking entry
+ * for a specific user and subtask
+ *
+ * @access public
+ * @param integer $user_id User id
+ * @param integer $subtask_id Subtask id
+ * @return \Picodb\Table
+ */
+ public function getOpenTimer($user_id, $subtask_id)
+ {
+ return $this->db
+ ->table(SubtaskTimeTrackingModel::TABLE)
+ ->columns(
+ SubtaskTimeTrackingModel::TABLE.'.id',
+ SubtaskTimeTrackingModel::TABLE.'.subtask_id',
+ SubtaskTimeTrackingModel::TABLE.'.end',
+ SubtaskTimeTrackingModel::TABLE.'.start',
+ SubtaskTimeTrackingModel::TABLE.'.time_spent',
+ SubtaskTimeTrackingModel::TABLE.'.comment',
+ SubtaskTimeTrackingModel::TABLE.'.is_billable'
+ )
+ ->eq(SubtaskTimeTrackingModel::TABLE.'.subtask_id', $subtask_id)
+ ->eq(SubtaskTimeTrackingModel::TABLE.'.user_id', $user_id)
+ ->eq(SubtaskTimeTrackingModel::TABLE.'.end', 0)
+ ->findOne();
+ }
+
+ /**
+ * Get by Id
+ *
+ * @access public
+ * @param int $id TimetrackingId
+ * @return \PicoDb\Table
+ */
+ public function getById($id)
+ {
+ return $this->db
+ ->table(SubtaskTimeTrackingModel::TABLE)
+ ->columns(
+ SubtaskTimeTrackingModel::TABLE.'.id',
+ SubtaskTimeTrackingModel::TABLE.'.subtask_id',
+ SubtaskTimeTrackingModel::TABLE.'.end',
+ SubtaskTimeTrackingModel::TABLE.'.start',
+ SubtaskTimeTrackingModel::TABLE.'.time_spent',
+ SubtaskTimeTrackingModel::TABLE.'.comment',
+ SubtaskTimeTrackingModel::TABLE.'.is_billable',
+ SubtaskModel::TABLE.'.task_id',
+ SubtaskModel::TABLE.'.title AS subtask_title',
+ TaskModel::TABLE.'.title AS task_title',
+ TaskModel::TABLE.'.project_id',
+ TaskModel::TABLE.'.color_id'
+ )
+ ->join(SubtaskModel::TABLE, 'id', 'subtask_id')
+ ->join(TaskModel::TABLE, 'id', 'task_id', SubtaskModel::TABLE)
+ ->eq(SubtaskTimeTrackingModel::TABLE.'.id', $id)
+ ->findOne();
+
+ }
+
+ /**
+ * Create a time tracking event
+ *
+ * @access public
+ * @param array $values Form values
+ * @return integer
+ */
+ public function create(array $values)
+ {
+
+ $this->prepare($values);
+ $subtrackingid = $this->db->table(SubtaskTimeTrackingModel::TABLE)->persist($values);
+
+ return (int) $subtrackingid;
+ }
+
+ /**
+ * Update a time tracking event
+ *
+ * @access public
+ * @param array $values
+ * @return boolean
+ */
+ public function update(array $values)
+ {
+ $this->prepare($values);
+
+ return $this->db->table(SubtaskTimeTrackingModel::TABLE)->eq('id', $values['id'])->update($values);
+ }
+
+
+ /**
+ * remove an entry
+ *
+ * @access public
+ * @param int $id
+ * @return boolran
+ */
+ public function remove($id)
+ {
+ return $this->db->table(SubtaskTimeTrackingModel::TABLE)->eq('id', $id)->remove();
+
+ }
+
+ /**
+ * Prepare data
+ *
+ * @access public
+ * @param array $values Form values
+ */
+ public function prepare(array &$values)
+ {
+ if ($this->userSession->isLogged()) {
+ $values['user_id'] = $this->userSession->getId();
+ }
+
+ $values["subtask_id"] = $values["opposite_subtask_id"];
+
+ $this->helper->model->removeFields($values, array('project_id', 'task_id', 'opposite_subtask_id', 'subtask', 'add_another', 'old_time_spent', 'old_opposite_subtask_id'));
+
+ // Calculate end time
+ $values = $this->dateParser->convert($values, array('start'), true);
+ $values["end"] = $values["start"] + ($values['time_spent']*60*60);
+ }
+}
diff --git a/plugins/Timetrackingeditor/Model/SubtaskTimeTrackingModel.php b/plugins/Timetrackingeditor/Model/SubtaskTimeTrackingModel.php
new file mode 100644
index 00000000..1fcf8738
--- /dev/null
+++ b/plugins/Timetrackingeditor/Model/SubtaskTimeTrackingModel.php
@@ -0,0 +1,133 @@
+<?php
+
+namespace Kanboard\Plugin\TimetrackingEditor\Model;
+
+use Kanboard\Model\SubtaskModel;
+use Kanboard\Model\TaskModel;
+use Kanboard\Model\UserModel;
+
+/**
+ * @author Thomas Stinner
+ */
+
+class SubtaskTimeTrackingModel extends \Kanboard\Model\SubtaskTimeTrackingModel
+{
+ /**
+ * Log start time
+ *
+ * @access public
+ * @param integer $subtask_id
+ * @param integer $user_id
+ * @return boolean
+ */
+ public function logStartTimeExtended($subtask_id, $user_id, $comment, $is_billable)
+ {
+ return
+ ! $this->hasTimer($subtask_id, $user_id) &&
+ $this->db
+ ->table(self::TABLE)
+ ->insert(array('subtask_id' => $subtask_id,
+ 'user_id' => $user_id,
+ 'comment' => $comment,
+ 'is_billable' => $is_billable,
+ 'start' => time(),
+ 'end' => 0));
+ }
+
+ /**
+ * Log end time
+ *
+ * @access public
+ * @param integer $subtask_id
+ * @param integer $user_id
+ * @return boolean
+ */
+ public function logEndTimeExtended($subtask_id, $user_id, $comment, $is_billable)
+ {
+ $time_spent = $this->getTimeSpent($subtask_id, $user_id);
+
+ if ($time_spent > 0) {
+ $this->updateSubtaskTimeSpent($subtask_id, $time_spent);
+ }
+
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('subtask_id', $subtask_id)
+ ->eq('user_id', $user_id)
+ ->eq('end', 0)
+ ->update(array(
+ 'end' => time(),
+ 'time_spent' => $time_spent,
+ 'comment' => $comment,
+ 'is_billable' => $is_billable
+ ));
+ }
+
+ /**
+ * Get query for task timesheet (pagination)
+ *
+ * @access public
+ * @param integer $task_id Task id
+ * @return \PicoDb\Table
+ */
+ public function getTaskQuery($task_id)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->columns(
+ self::TABLE.'.id',
+ self::TABLE.'.subtask_id',
+ self::TABLE.'.end',
+ self::TABLE.'.start',
+ self::TABLE.'.time_spent',
+ self::TABLE.'.user_id',
+ self::TABLE.'.comment',
+ self::TABLE.'.is_billable',
+ SubtaskModel::TABLE.'.task_id',
+ SubtaskModel::TABLE.'.title AS subtask_title',
+ TaskModel::TABLE.'.project_id',
+ UserModel::TABLE.'.username',
+ UserModel::TABLE.'.name AS user_fullname'
+ )
+ ->join(SubtaskModel::TABLE, 'id', 'subtask_id')
+ ->join(TaskModel::TABLE, 'id', 'task_id', SubtaskModel::TABLE)
+ ->join(UserModel::TABLE, 'id', 'user_id', self::TABLE)
+ ->eq(TaskModel::TABLE.'.id', $task_id);
+ }
+
+ /**
+ * Update subtask time billable
+ *
+ * @access public
+ * @param integer $subtask_id
+ * @param float $time_billable
+ * @return bool
+ */
+ public function updateSubtaskTimeBillable($subtask_id, $time_billable)
+ {
+ $subtask = $this->subtaskModel->getById($subtask_id);
+
+ return $this->subtaskModel->update(array(
+ 'id' => $subtask['id'],
+ 'time_billable' => $subtask['time_billable'] + $time_billable,
+ 'task_id' => $subtask['task_id'],
+ ), false);
+ }
+
+ /**
+ * get a Subtasktimetracking entry by Id
+ *
+ * @access public
+ * @param $id the subtasktimetracking id
+ * @return array
+ */
+ public function getById($id)
+ {
+ return $this->db
+ ->table(SubtaskTimeTrackingModel::TABLE)
+ ->eq('id', $id)
+ ->findOne();
+ }
+
+
+}
diff --git a/plugins/Timetrackingeditor/Plugin.php b/plugins/Timetrackingeditor/Plugin.php
new file mode 100644
index 00000000..f36403fb
--- /dev/null
+++ b/plugins/Timetrackingeditor/Plugin.php
@@ -0,0 +1,91 @@
+<?php
+
+namespace Kanboard\Plugin\Timetrackingeditor;
+
+use Kanboard\Core\Translator;
+use Kanboard\Core\Plugin\Base;
+use Kanboard\Plugin\Timetrackingeditor\Helper\SubtaskHelper;
+use Kanboard\Plugin\Timetrackingeditor\Model\SubtaskTimeTrackingModel;
+use Kanboard\Plugin\Timetrackingeditor\Console\AllSubtaskTimeTrackingExportCommand;
+
+class Plugin extends Base
+{
+ public function initialize()
+ {
+ $this->hook->on("template:layout:css", array("template" => "plugins/Timetrackingeditor/assets/css/timetrackingeditor.css"));
+ $this->template->setTemplateOverride('task/time_tracking_details', 'timetrackingeditor:time_tracking_editor');
+ $this->template->setTemplateOverride('subtask/table', 'timetrackingeditor:subtask/table');
+
+ # $this->helper->register("subtask", "Kanboard\Plugin\Timetrackingeditor\Helper\SubtaskHelper");
+
+ $this->container["cli"]->add(new AllSubtaskTimeTrackingExportCommand($this->container));
+ }
+
+ public function onStartup()
+ {
+ Translator::load($this->languageModel->getCurrentLanguage(), __DIR__.'/Locale');
+ }
+
+ public function getClasses()
+ {
+ return array(
+ 'Plugin\Timetrackingeditor\Model' => array(
+ 'SubtaskTimeTrackingCreationModel',
+ 'SubtaskTimeTrackingEditModel',
+ 'SubtaskTimeTrackingModel',
+ ),
+ 'Plugin\Timetrackingeditor\Filter' => array(
+ 'SubtaskFilter',
+ 'SubtaskTaskFilter',
+ 'SubtaskTitleFilter'
+ ),
+ 'Plugin\Timetrackingeditor\Console' => array(
+ 'AllSubtaskTimeTrackingExportCommand'
+ ),
+ 'Plugin\Timetrackingeditor\Controller' => array(
+ 'SubtaskStatusController',
+ 'SubtaskAjaxController',
+ 'TimeTrackingEditorController'
+ ),
+ 'Plugin\Timetrackingeditor\Export' => array(
+ 'SubtaskTimeTrackingExport'
+ ),
+ 'Plugin\Timetrackingeditor\Validator' => array(
+ 'SubtaskTimeTrackingValidator'
+ ),
+ 'Plugin\Timetrackingeditor\Formatter' => array(
+ 'SubtaskAutoCompleteFormatter'
+ ),
+ );
+ }
+
+ public function getPluginName()
+ {
+ return 'TimeTrackingEditor';
+ }
+
+ public function getPluginDescription()
+ {
+ return t('Allows Editing of TimeTracking Values');
+ }
+
+ public function getPluginAuthor()
+ {
+ return 'Thomas Stinner';
+ }
+
+ public function getPluginVersion()
+ {
+ return '1.0.21';
+ }
+
+ public function getPluginHomepage()
+ {
+ return 'https://github.com/stinnux/kanboard-timetrackingeditor';
+ }
+
+ public function getCompatibleVersion()
+ {
+ return '>=1.2.4';
+ }
+}
diff --git a/plugins/Timetrackingeditor/README.md b/plugins/Timetrackingeditor/README.md
new file mode 100644
index 00000000..68907fc0
--- /dev/null
+++ b/plugins/Timetrackingeditor/README.md
@@ -0,0 +1,40 @@
+Timetrackingeditor
+==================
+
+
+Allows manual adding and editon of Timetracking Entries
+
+Author
+------
+
+- Thomas Stinner
+
+Requirements
+------------
+
+- Kanboard >= 1.0.38 (not testet with older versions)
+
+
+Installation
+------------
+
+You have the choice between 3 methods:
+
+1. Install the plugin from the Kanboard plugin manager in one click
+2. Download the zip file and decompress everything under the directory `plugins/Timetrackingeditor`
+3. Clone this repository into the folder `plugins/Timetrackingeditor`
+
+Note: Plugin folder is case-sensitive.
+
+
+Documentation
+-------------
+
+With this plugin you are able to edit, remove and manually create entries in the Time Tracking Table.
+
+Just go to a subtask, select "Time Tracking" (only visible if you have entered either an estimate and/or time spent value for the Task). Now you have the opportunity to add/remove/delete entries. You are only allowed to remove and edit your own entries.
+
+You can also add comments to every Time Tracking entry and select if the time is billable or not. Entries that have been selected as billable have a shopping cart symbol.
+
+Additionally you can export all time tracking entries as an HTML table (which makes it easy to import to excel) using the command ``` kanboard export:allsubtaskstimetracking```
+
diff --git a/plugins/Timetrackingeditor/Schema/Mysql.php b/plugins/Timetrackingeditor/Schema/Mysql.php
new file mode 100644
index 00000000..8fda41c7
--- /dev/null
+++ b/plugins/Timetrackingeditor/Schema/Mysql.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace Kanboard\Plugin\Timetrackingeditor\Schema;
+
+const VERSION = 2;
+
+function version_2($pdo)
+{
+ $pdo->exec("ALTER TABLE subtasks add time_billable INT default 0");
+}
+
+function version_1($pdo)
+{
+ $pdo->exec("ALTER TABLE subtask_time_tracking add comment TEXT");
+ $pdo->exec("ALTER TABLE subtask_time_tracking add is_billable TINYINT");
+}
diff --git a/plugins/Timetrackingeditor/Schema/Postgres.php b/plugins/Timetrackingeditor/Schema/Postgres.php
new file mode 100644
index 00000000..b735e931
--- /dev/null
+++ b/plugins/Timetrackingeditor/Schema/Postgres.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace Kanboard\Plugin\Timetrackingeditor\Schema;
+
+const VERSION = 2;
+
+function vesion_2($pdo)
+{
+ $pdo->exec("ALTER TABLE subtasks add time_billable INTEGER default 0");
+}
+
+function version_1($pdo)
+{
+ $pdo->exec("ALTER TABLE subtask_time_tracking add comment TEXT");
+ $pdo->exec("ALTER TABLE subtask_time_tracking add is_billable BOOLEAN");
+}
diff --git a/plugins/Timetrackingeditor/Schema/Sqlite.php b/plugins/Timetrackingeditor/Schema/Sqlite.php
new file mode 100644
index 00000000..ffbeac85
--- /dev/null
+++ b/plugins/Timetrackingeditor/Schema/Sqlite.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace Kanboard\Plugin\Timetrackingeditor\Schema;
+
+const VERSION = 2;
+
+function version_2($pdo)
+{
+ $pdo->exec("ALTER TABLE subtasks add time_billable NUMERIC default 0");
+}
+
+function version_1($pdo)
+{
+ $pdo->exec("ALTER TABLE subtask_time_tracking add comment TEXT");
+ $pdo->exec("ALTER TABLE subtask_time_tracking add is_billable INTEGER");
+}
diff --git a/plugins/Timetrackingeditor/Template/create.php b/plugins/Timetrackingeditor/Template/create.php
new file mode 100644
index 00000000..362cfde4
--- /dev/null
+++ b/plugins/Timetrackingeditor/Template/create.php
@@ -0,0 +1,41 @@
+<div class="page-header">
+ <h2><?= t('Add a new Time Tracking Event') ?></h2>
+</div>
+<form class="popover-form" method="post" action="<?= $this->url->href('TimeTrackingEditorController', 'save', array('plugin' => 'timetrackingeditor', 'project_id' => $values['project_id'], 'task_id' => $values['task_id'])) ?>" autocomplete="off">
+
+ <?= $this->form->csrf() ?>
+
+ <?= $this->form->hidden('project_id', $values) ?>
+ <?= $this->form->hidden('task_id', $values) ?>
+ <?= $this->form->hidden('opposite_subtask_id', $values) ?>
+
+ <?= $this->form->label(t('Subtask'), 'subtask') ?>
+ <?= $this->form->text(
+ 'subtask',
+ $values,
+ $errors,
+ array(
+ 'required',
+ (!isset($autofocus) || $autofocus == 'subtask' ? 'autofocus' : ''),
+ 'placeholder="'.t('Start to type subtask title...').'"',
+ 'title="'.t('Start to type subtask title...').'"',
+ 'data-dst-field="opposite_subtask_id"',
+ 'data-search-url="'.$this->url->href('SubtaskAjaxController', 'autocomplete', array('plugin' => 'timetrackingeditor', 'task_id' => $values['task_id'])).'"',
+ ),
+ 'autocomplete') ?>
+
+ <?= $this->form->label(t('Time spent'), 'time_spent') ?>
+ <?= $this->form->numeric('time_spent', $values, $errors, array('maxlength="10"', 'required', (isset($autofocus) && $autofocus == "time_spent" ? 'autofocus' : '')), 'form-numeric') ?> hours
+
+ <?= $this->form->label(t('Start Date'), 'start') ?>
+ <?= $this->form->text('start', $values, $errors, array('maxlength="10"', 'required'), 'form-date') ?>
+
+ <?= $this->form->label(t('Comment'), 'comment') ?>
+ <?= $this->form->textarea('comment', $values, $errors, array(), 'markdown-editor') ?>
+
+ <?= $this->form->checkbox('is_billable', t('Billable?'), 1, isset($values['is_billable']) && $values['is_billable'] == 1) ?>
+ <?= $this->form->checkbox('add_another', t('Add another event'), 1, isset($values['add_another']) && $values['add_another'] == 1) ?>
+
+ <?= $this->modal->submitButtons(); ?>
+
+</form>
diff --git a/plugins/Timetrackingeditor/Template/edit.php b/plugins/Timetrackingeditor/Template/edit.php
new file mode 100644
index 00000000..28323812
--- /dev/null
+++ b/plugins/Timetrackingeditor/Template/edit.php
@@ -0,0 +1,41 @@
+<div class="page-header">
+ <h2><?= t('Edit a Time Tracking Event') ?></h2>
+</div>
+<form class="popover-form" method="post" action="<?= $this->url->href('TimeTrackingEditorController', 'update', array('plugin' => 'timetrackingeditor', 'project_id' => $values['project_id'], 'task_id' => $values['task_id'])) ?>" autocomplete="off">
+
+ <?= $this->form->csrf() ?>
+
+ <?= $this->form->hidden('project_id', $values) ?>
+ <?= $this->form->hidden('task_id', $values) ?>
+ <?= $this->form->hidden('opposite_subtask_id', $values) ?>
+ <?= $this->form->hidden('id', $values) ?>
+
+
+ <?= $this->form->label(t('Subtask'), 'subtask') ?>
+ <?= $this->form->text(
+ 'subtask',
+ $values,
+ $errors,
+ array(
+ 'required',
+ 'autofocus',
+ 'placeholder="'.t('Start to type subtask title...').'"',
+ 'title="'.t('Start to type subtask title...').'"',
+ 'data-dst-field="opposite_subtask_id"',
+ 'data-search-url="'.$this->url->href('SubtaskAjaxController', 'autocomplete', array('plugin' => 'timetrackingeditor', 'task_id' => $values['task_id'])).'"',
+ ),
+ 'autocomplete') ?>
+
+ <?= $this->form->label(t('Start Date'), 'start') ?>
+ <?= $this->form->text('start', $values, $errors, array('maxlength="10"', 'required'), 'form-date') ?>
+
+ <?= $this->form->label(t('Time spent'), 'time_spent') ?>
+ <?= $this->form->numeric('time_spent', $values, $errors, array('maxlength="10"', 'required'), 'form-numeric') ?> hours
+
+ <?= $this->form->label(t('Comment'), 'comment') ?>
+ <?= $this->form->textarea('comment', $values, $errors, array(), 'markdown-editor') ?>
+
+ <?= $this->form->checkbox('is_billable', t('Billable?'), 1, $values['is_billable'] == 1) ?>
+
+ <?= $this->modal->submitButtons(); ?>
+</form>
diff --git a/plugins/Timetrackingeditor/Template/menu.php b/plugins/Timetrackingeditor/Template/menu.php
new file mode 100644
index 00000000..fb715c4b
--- /dev/null
+++ b/plugins/Timetrackingeditor/Template/menu.php
@@ -0,0 +1,23 @@
+<div class="dropdown">
+ <a href="#" class="dropdown-menu dropdown-menu-link-icon"><i class="fa fa-cog fa-fw"></i><i class="fa fa-caret-down"></i></a>
+ <ul>
+ <li>
+ <?= $this->modal->medium("pencil-square-o", t('Edit'),
+ 'TimeTrackingEditorController', 'edit',
+ array('plugin' => 'timetrackingeditor',
+ 'task_id' => $task['id'],
+ 'project_id' => $task['project_id'],
+ 'subtask_id' => $subtask_id,
+ 'id' => $id)) ?>
+ </li>
+ <li>
+ <?= $this->modal->medium("trash-o", t('Remove'),
+ 'TimeTrackingEditorController', 'confirm',
+ array('plugin' => 'timetrackingeditor',
+ 'task_id' => $task['id'],
+ 'project_id' => $task['project_id'],
+ 'subtask_id' => $subtask_id,
+ 'id' => $id)) ?>
+ </li>
+ </ul>
+</div>
diff --git a/plugins/Timetrackingeditor/Template/remove.php b/plugins/Timetrackingeditor/Template/remove.php
new file mode 100644
index 00000000..f86ad424
--- /dev/null
+++ b/plugins/Timetrackingeditor/Template/remove.php
@@ -0,0 +1,20 @@
+<div class="page-header">
+ <h2><?= t('Remove a time tracking entry') ?></h2>
+</div>
+
+<div class="confirm">
+ <div class="alert alert-info">
+ <?= t('Do you really want to remove this entry?') ?>
+ <ul>
+ <li>
+ <strong><?= $this->text->e($timetracking['subtask_title']) ?></strong>
+ </li>
+ </ul>
+ </div>
+
+ <div class="form-actions">
+ <?= $this->url->link(t('Yes'), 'TimeTrackingEditorController', 'remove', array('plugin' => 'timetrackingeditor', 'id' => $timetracking['id'], 'project_id' => $timetracking['project_id'], 'subtask_id' => $timetracking['subtask_id']), true, 'btn btn-red') ?>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'TimeTrackingEditorController', 'show', array('plugin' => 'timetrackingeditor','id' => $timetracking['id'], 'project_id' => $timetracking['project_id']), false, 'close-popover') ?>
+ </div>
+</div>
diff --git a/plugins/Timetrackingeditor/Template/start.php b/plugins/Timetrackingeditor/Template/start.php
new file mode 100644
index 00000000..cc67febd
--- /dev/null
+++ b/plugins/Timetrackingeditor/Template/start.php
@@ -0,0 +1,23 @@
+<div class="page-header">
+ <h2><?= t('Start a new Timer') ?></h2>
+</div>
+<form class="popover-form" method="post" action="<?= $this->url->href('TimeTrackingEditorController', 'startsave', array('plugin' => 'timetrackingeditor', 'project_id' => $values['project_id'], 'task_id' => $values['task_id'])) ?>" autocomplete="off">
+
+ <?= $this->form->csrf() ?>
+
+ <?= $this->form->hidden('project_id', $values) ?>
+ <?= $this->form->hidden('task_id', $values) ?>
+ <?= $this->form->hidden('subtask_id', $values) ?>
+
+ <?= t('Subtask') ?>
+ <?= $values['subtask']['title'] ?>
+
+ <?= $this->form->label(t('Comment'), 'comment') ?>
+ <?= $this->form->textarea('comment', $values, $errors, array(), 'markdown-editor') ?>
+
+ <?= $this->form->checkbox('is_billable', t('Billable?'), 1, isset($values['is_billable']) && $values['is_billable'] == 1) ?>
+
+ <div class="form-actions">
+ <?= $this->modal->submitButtons() ?>
+ </div>
+</form>
diff --git a/plugins/Timetrackingeditor/Template/stop.php b/plugins/Timetrackingeditor/Template/stop.php
new file mode 100644
index 00000000..63d3905d
--- /dev/null
+++ b/plugins/Timetrackingeditor/Template/stop.php
@@ -0,0 +1,23 @@
+<div class="page-header">
+ <h2><?= t('Stop a Timer') ?></h2>
+</div>
+<form class="popover-form" method="post" action="<?= $this->url->href('TimeTrackingEditorController', 'stopsave', array('plugin' => 'timetrackingeditor', 'project_id' => $values['project_id'], 'task_id' => $values['task_id'])) ?>" autocomplete="off">
+
+ <?= $this->form->csrf() ?>
+
+ <?= $this->form->hidden('project_id', $values) ?>
+ <?= $this->form->hidden('task_id', $values) ?>
+ <?= $this->form->hidden('subtask_id', $values) ?>
+
+ <?= t('Subtask') ?>
+ <?= $values['subtask']['title'] ?>
+
+ <?= $this->form->label(t('Comment'), 'comment') ?>
+ <?= $this->form->textarea('comment', $values, $errors, array(), 'markdown-editor') ?>
+
+ <?= $this->form->checkbox('is_billable', t('Billable?'), 1, isset($values['is_billable']) && $values['is_billable'] == 1) ?>
+
+ <div class="form-actions">
+ <?= $this->modal->submitButtons() ?>
+ </div>
+</form>
diff --git a/plugins/Timetrackingeditor/Template/subtask/table.php b/plugins/Timetrackingeditor/Template/subtask/table.php
new file mode 100644
index 00000000..fa7f62ff
--- /dev/null
+++ b/plugins/Timetrackingeditor/Template/subtask/table.php
@@ -0,0 +1,89 @@
+<?php if (! empty($subtasks)): ?>
+ <table
+ class="subtasks-table table-stripped"
+ data-save-position-url="<?= $this->url->href('SubtaskController', 'movePosition', array('project_id' => $task['project_id'], 'task_id' => $task['id'])) ?>"
+ >
+ <thead>
+ <tr>
+ <th class="column-40"><?= t('Title') ?></th>
+ <th><?= t('Assignee') ?></th>
+ <th><?= t('Time tracking') ?></th>
+ <?php if ($editable): ?>
+ <th class="column-5"></th>
+ <th class="column-5"></th>
+ <?php endif ?>
+ </tr>
+ </thead>
+ <tbody>
+ <?php foreach ($subtasks as $subtask): ?>
+ <tr data-subtask-id="<?= $subtask['id'] ?>">
+ <td>
+ <?php if ($editable): ?>
+ <i class="fa fa-arrows-alt draggable-row-handle" title="<?= t('Change subtask position') ?>"></i>
+ <?= $this->subtask->renderToggleStatus($task, $subtask, "table") ?>
+ <?php else: ?>
+ <?= $this->subtask->getTitle($subtask) ?>
+ <?php endif ?>
+ </td>
+ <td>
+ <?php if (! empty($subtask['username'])): ?>
+ <?= $this->text->e($subtask['name'] ?: $subtask['username']) ?>
+ <?php endif ?>
+ </td>
+ <td>
+ <ul class="no-bullet">
+ <li>
+ <?php if (! empty($subtask['time_spent'])): ?>
+ <strong><?= $this->text->e($subtask['time_spent']).'h' ?></strong> <?= t('spent') ?>
+ <?php endif ?>
+
+ <?php if (! empty($subtask['time_estimated'])): ?>
+ <strong><?= $this->text->e($subtask['time_estimated']).'h' ?></strong> <?= t('estimated') ?>
+ <?php endif ?>
+ <?php if (! empty($subtask['time_billable'])): ?>
+ <strong><?= $this->text->e($subtask['time_billable']).'h' ?></strong> <?= t('billable') ?>
+ <?php endif ?>
+
+ </li>
+ <?php if ($editable && $subtask['user_id'] == $this->user->getId()): ?>
+ <li>
+ <?php if ($subtask['is_timer_started']): ?>
+ <?= $this->modal->medium("pause",t('Stop timer'), 'TimeTrackingEditorController', 'stop',
+ array('plugin' => 'Timetrackingeditor',
+ 'project_id' => $task['project_id'],
+ 'task_id' => $subtask['task_id'],
+ 'subtask_id' => $subtask['id'])) ?>
+ (<?= $this->dt->age($subtask['timer_start_date']) ?>)
+ <?php else: ?>
+ <?= $this->modal->medium("play-circle-o",t('Start timer'), 'TimeTrackingEditorController', 'start',
+ array('plugin' => 'Timetrackingeditor',
+ 'project_id' => $task['project_id'],
+ 'task_id' => $subtask['task_id'],
+ 'subtask_id' => $subtask['id'])) ?>
+ <?php endif ?>
+ </li>
+ <?php endif ?>
+ </ul>
+ </td>
+ <?php if ($editable): ?>
+ <td>
+ <?= $this->render('subtask/menu', array(
+ 'task' => $task,
+ 'subtask' => $subtask,
+ )) ?>
+ </td>
+ <td>
+ <?= $this->modal->medium("clock-o", t('New'), 'TimeTrackingEditorController',
+ 'create', array(
+ 'plugin' => 'Timetrackingeditor',
+ 'task_id' => $task['id'],
+ 'project_id' => $task['project_id'],
+ 'subtask_id' => $subtask['id'])) ?>
+ </td>
+
+ <?php endif ?>
+ </tr>
+ <?php endforeach ?>
+ </tbody>
+ </table>
+<?php endif ?>
diff --git a/plugins/Timetrackingeditor/Template/time_tracking_editor.php b/plugins/Timetrackingeditor/Template/time_tracking_editor.php
new file mode 100644
index 00000000..2457305b
--- /dev/null
+++ b/plugins/Timetrackingeditor/Template/time_tracking_editor.php
@@ -0,0 +1,53 @@
+<div class="task-show-title color-<?= $task['color_id'] ?>">
+ <h2><?= $this->text->e($task['title']) ?></h2>
+</div>
+
+<?= $this->render('task/time_tracking_summary', array('task' => $task)) ?>
+
+<h3><?= t('Subtask timesheet') ?></h3>
+
+<?= $this->modal->medium("plus",t('Add a new timetracking entry'), 'TimeTrackingEditorController',
+ 'create', array(
+ 'plugin' => 'timetrackingeditor',
+ 'task_id' => $task['id'],
+ 'project_id' => $task['project_id'])) ?>
+
+<?php if ($subtask_paginator->isEmpty()): ?>
+ <p class="alert"><?= t('There is nothing to show.') ?></p>
+<?php else: ?>
+ <table class="table-fixed">
+ <tr>
+ <th class="column-15"><?= $subtask_paginator->order(t('User'), 'username') ?></th>
+ <th><?= $subtask_paginator->order(t('Subtask'), 'subtask_title') ?></th>
+ <th class="column-20"><?= $subtask_paginator->order(t('Start'), 'start') ?></th>
+ <th class="column-20"><?= $subtask_paginator->order(t('End'), 'end') ?></th>
+ <th class="column-10 right"><?= $subtask_paginator->order(t('Time spent'), \Kanboard\Model\SubtaskTimeTrackingModel::TABLE.'.time_spent') ?></th>
+ <th class="column-10"></th>
+ </tr>
+ <?php foreach ($subtask_paginator->getCollection() as $record): ?>
+ <tr>
+ <td><?= $this->url->link($this->text->e($record['user_fullname'] ?: $record['username']), 'UserViewController', 'show', array('user_id' => $record['user_id'])) ?>
+ <?php if ($record['is_billable']): ?>
+ <i class='fa fa-cart-plus'></i>
+ <?php endif ?>
+ <?= $this->app->tooltipMarkdown($record['comment']) ?>
+ </td>
+ <td><?= t($record['subtask_title']) ?></td>
+ <td><?= $this->dt->datetime($record['start']) ?></td>
+ <td><?= $this->dt->datetime($record['end']) ?></td>
+ <td class="right"><?= n($record['time_spent']).' '.t('hours') ?></td>
+ <td>
+ <?php if ($this->user->isCurrentUser($record['user_id'])) { ?>
+ <?= $this->render('timetrackingeditor:menu', array(
+ 'task' => $task,
+ 'subtask_id' => $record['subtask_id'],
+ 'id' => $record['id']
+ )) ?>
+ <?php } ?>
+ </td>
+ </tr>
+ <?php endforeach ?>
+ </table>
+
+ <?= $subtask_paginator ?>
+<?php endif ?>
diff --git a/plugins/Timetrackingeditor/Test/PluginTest.php b/plugins/Timetrackingeditor/Test/PluginTest.php
new file mode 100644
index 00000000..e563e9e4
--- /dev/null
+++ b/plugins/Timetrackingeditor/Test/PluginTest.php
@@ -0,0 +1,17 @@
+<?php
+require_once 'tests/units/Base.php';
+use Kanboard\Plugin\Timetable\Plugin;
+class PluginTest extends Base
+{
+ public function testPlugin()
+ {
+ $plugin = new Plugin($this->container);
+ $this->assertSame(null, $plugin->initialize());
+ $this->assertSame(null, $plugin->onStartup());
+ $this->assertNotEmpty($plugin->getPluginName());
+ $this->assertNotEmpty($plugin->getPluginDescription());
+ $this->assertNotEmpty($plugin->getPluginAuthor());
+ $this->assertNotEmpty($plugin->getPluginVersion());
+ $this->assertNotEmpty($plugin->getPluginHomepage());
+ }
+}
diff --git a/plugins/Timetrackingeditor/Test/TimetrackingeditorTest.php b/plugins/Timetrackingeditor/Test/TimetrackingeditorTest.php
new file mode 100644
index 00000000..551d9b10
--- /dev/null
+++ b/plugins/Timetrackingeditor/Test/TimetrackingeditorTest.php
@@ -0,0 +1,48 @@
+
+<?php
+
+require_once 'tests/units/BaseProcedureTest.php';
+
+use Kanboard\Core\Plugin\Loader;
+use Kanboard\
+use Kanboard\Plugin\Timetrackingeditor\SubtasktimetrackingCreationModel;
+use Kanboard\Plugin\Timetrackingeditor\SubtasktimetrackingEditModel;
+
+class TimetrackingeditorTest extends BaseProcedureTest
+{
+ protected $projectName = 'My project to test time tracking';
+ protected $project_id;
+ protected $task_id;
+ protected $subtask_id;
+
+
+ public function setUp()
+ {
+ parent::setUp();
+
+ $plugin = new Load($this->container);
+ $plugin->scan();
+ }
+
+ public function testAll()
+ {
+ $this->assertCreateTeamProject();
+
+ $this->task_id = $this->app->createTask(array('project_id' => $this->projectId, 'title' => 'Task 1'));
+ $this->subtask_id = $this->app->createSubTask(array('project_id' => $this->projectId, 'task_id' => $this->task_id, 'title' => 'Subtask 1'));
+
+ $this->assertNotFalse($this->task_id);
+ $this->assertNotFalse($this->subtask_id);
+
+ $this->testCreateEntry();
+ }
+
+ public function testCreateEntry()
+ {
+
+
+
+ }
+
+
+}
diff --git a/plugins/Timetrackingeditor/Validator/SubtaskTimeTrackingValidator.php b/plugins/Timetrackingeditor/Validator/SubtaskTimeTrackingValidator.php
new file mode 100644
index 00000000..6bddc47a
--- /dev/null
+++ b/plugins/Timetrackingeditor/Validator/SubtaskTimeTrackingValidator.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Kanboard\Plugin\Timetrackingeditor\Validator;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+use Kanboard\Validator\BaseValidator;
+
+/**
+ * SubtaskTimetracking Validator
+ *
+ * @package Kanboard\Plugin\Timetrackingeditor\Validator
+ * @author Thomas Stinner
+ */
+class SubtaskTimeTrackingValidator extends BaseValidator
+{
+ /**
+ * Validate creation
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $rules = array ();
+
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $rules = array (
+ new Validators\Required('id', t('The Timetracking id is required'))
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Common validation rules, valid for creation and modification
+ *
+ * @access private
+ */
+ private function commonValidationRules()
+ {
+ $rules = array(
+ new Validators\Required('task_id', t('The Task id is required')),
+ new Validators\Required('opposite_subtask_id', t('The subtask is required')),
+ new Validators\Required('start', t('The Start Date is required')),
+ new Validators\Required('time_spent', t('The Time spent is required')),
+ );
+ return $rules;
+ }
+}
diff --git a/plugins/Timetrackingeditor/assets/css/timetrackingeditor.css b/plugins/Timetrackingeditor/assets/css/timetrackingeditor.css
new file mode 100644
index 00000000..f2a9d586
--- /dev/null
+++ b/plugins/Timetrackingeditor/assets/css/timetrackingeditor.css
@@ -0,0 +1,3 @@
+.right {
+ text-align: right;
+}