diff options
author | emkael <emkael@tlen.pl> | 2020-05-05 14:25:42 +0200 |
---|---|---|
committer | emkael <emkael@tlen.pl> | 2020-05-05 14:25:42 +0200 |
commit | 62827e6cf470449c117624058fb36ad94804bcc0 (patch) | |
tree | 10cd1e4d36c34b694acfadaa69fc7f6ae2b1eabd /plugins/Timetrackingeditor | |
parent | 7b66ddf2e4fbdb837e78d8b7dbaa9fc38391bc32 (diff) |
Diffstat (limited to 'plugins/Timetrackingeditor')
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; +} |