diff --git a/controllers/TasksApiController.php b/controllers/TasksApiController.php new file mode 100644 index 00000000..5a1f83f9 --- /dev/null +++ b/controllers/TasksApiController.php @@ -0,0 +1,21 @@ +TasksService = new TasksService(); + } + + protected $TasksService; + + public function Current(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + return $this->ApiResponse($this->TasksService->GetCurrent()); + } +} diff --git a/controllers/TasksController.php b/controllers/TasksController.php new file mode 100644 index 00000000..b08f0ad3 --- /dev/null +++ b/controllers/TasksController.php @@ -0,0 +1,43 @@ +TasksService = new TasksService(); + } + + protected $TasksService; + + public function Overview(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + return $this->AppContainer->view->render($response, 'tasks', [ + 'tasks' => $this->Database->tasks()->orderBy('name'), + 'nextXDays' => 5 + ]); + } + + public function TaskEditForm(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + if ($args['taskdId'] == 'new') + { + return $this->AppContainer->view->render($response, 'taskform', [ + 'mode' => 'create', + 'taskCategories' => $this->Database->task_categories()->orderBy('name') + ]); + } + else + { + return $this->AppContainer->view->render($response, 'taskform', [ + 'task' => $this->Database->tasks($args['taskId']), + 'mode' => 'edit', + 'taskCategories' => $this->Database->task_categories()->orderBy('name') + ]); + } + } +} diff --git a/grocy.openapi.json b/grocy.openapi.json index 9fd2afd2..ab62d917 100644 --- a/grocy.openapi.json +++ b/grocy.openapi.json @@ -1278,6 +1278,29 @@ } } } + }, + "/tasks/get-current": { + "get": { + "description": "Returns all tasks which are not done yet", + "tags": [ + "Tasks" + ], + "responses": { + "200": { + "description": "An array of Task objects", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Task" + } + } + } + } + } + } + } } }, "components": { @@ -1292,7 +1315,8 @@ "quantity_units", "shopping_list", "recipes", - "recipes_pos" + "recipes_pos", + "tasks" ] }, "StockTransactionType": { @@ -1898,6 +1922,25 @@ } } } + }, + "Task": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "due": { + "type": "string", + "format": "date-time" + }, + "row_created_timestamp": { + "type": "string", + "format": "date-time" + } + } } }, "examples": { diff --git a/migrations/0036.sql b/migrations/0036.sql new file mode 100644 index 00000000..dd9a5c91 --- /dev/null +++ b/migrations/0036.sql @@ -0,0 +1,24 @@ +CREATE TABLE tasks ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE, + name TEXT NOT NULL UNIQUE, + description TEXT, + due DATETIME, + started TINYINT NOT NULL DEFAULT 0 CHECK(started IN (0, 1)), + done TINYINT NOT NULL DEFAULT 0 CHECK(done IN (0, 1)), + category_id INTEGER, + row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime')) +); + +CREATE TABLE task_categories ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE, + name TEXT NOT NULL UNIQUE, + description TEXT, + row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime')) +); + +CREATE VIEW tasks_current +AS +SELECT * +FROM tasks +WHERE due IS NULL + OR (due IS NOT NULL AND due > datetime('now', 'localtime')); diff --git a/public/viewjs/taskform.js b/public/viewjs/taskform.js new file mode 100644 index 00000000..3fd01dd0 --- /dev/null +++ b/public/viewjs/taskform.js @@ -0,0 +1,55 @@ +$('#save-task-button').on('click', function(e) +{ + e.preventDefault(); + + if (Grocy.EditMode === 'create') + { + Grocy.Api.Post('add-object/tasks', $('#task-form').serializeJSON(), + function(result) + { + window.location.href = U('/tasks'); + }, + function(xhr) + { + Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response) + } + ); + } + else + { + Grocy.Api.Post('edit-object/tasks/' + Grocy.EditObjectId, $('#task-form').serializeJSON(), + function(result) + { + window.location.href = U('/tasks'); + }, + function(xhr) + { + Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response) + } + ); + } +}); + +$('#task-form input').keyup(function (event) +{ + Grocy.FrontendHelpers.ValidateForm('task-form'); +}); + +$('#task-form input').keydown(function (event) +{ + if (event.keyCode === 13) //Enter + { + if (document.getElementById('task-form').checkValidity() === false) //There is at least one validation error + { + event.preventDefault(); + return false; + } + else + { + $('#save-task-button').click(); + } + } +}); + +$('#name').focus(); +Grocy.FrontendHelpers.ValidateForm('task-form'); diff --git a/public/viewjs/tasks.js b/public/viewjs/tasks.js new file mode 100644 index 00000000..868dca0f --- /dev/null +++ b/public/viewjs/tasks.js @@ -0,0 +1,121 @@ +var tasksTable = $('#tasks-table').DataTable({ + 'paginate': false, + 'order': [[2, 'desc']], + 'columnDefs': [ + { 'orderable': false, 'targets': 0 } + ], + 'language': JSON.parse(L('datatables_localization')), + 'scrollY': false, + 'colReorder': true, + 'stateSave': true, + 'stateSaveParams': function(settings, data) + { + data.search.search = ""; + } +}); + +$("#search").on("keyup", function() +{ + var value = $(this).val(); + if (value === "all") + { + value = ""; + } + + tasksTable.search(value).draw(); +}); + +$(document).on('click', '.do-task-button', function(e) +{ + var taskId = $(e.currentTarget).attr('data-task-id'); + var doneTime = moment().format('YYYY-MM-DD HH:mm:ss'); + + Grocy.Api.Get('tasks/track-task-execution/' + taskId + '?tracked_time=' + trackedTime, + function() + { + Grocy.Api.Get('tasks/get-task-details/' + taskId, + function(result) + { + var taskRow = $('#task-' + taskId + '-row'); + var nextXDaysThreshold = moment().add($("#info-due-tasks").data("next-x-days"), "days"); + var now = moment(); + var nextExecutionTime = moment(result.next_estimated_execution_time); + + taskRow.removeClass("table-warning"); + taskRow.removeClass("table-danger"); + if (nextExecutionTime.isBefore(now)) + { + taskRow.addClass("table-danger"); + } + else if (nextExecutionTime.isBefore(nextXDaysThreshold)) + { + taskRow.addClass("table-warning"); + } + + $('#task-' + taskId + '-last-tracked-time').parent().effect('highlight', { }, 500); + $('#task-' + taskId + '-last-tracked-time').fadeOut(500, function() + { + $(this).text(trackedTime).fadeIn(500); + }); + $('#task-' + taskId + '-last-tracked-time-timeago').attr('datetime', trackedTime); + + if (result.task.period_type == "dynamic-regular") + { + $('#task-' + taskId + '-next-execution-time').parent().effect('highlight', { }, 500); + $('#task-' + taskId + '-next-execution-time').fadeOut(500, function() + { + $(this).text(result.next_estimated_execution_time).fadeIn(500); + }); + $('#task-' + taskId + '-next-execution-time-timeago').attr('datetime', result.next_estimated_execution_time); + } + + toastr.success(L('Tracked execution of task #1 on #2', taskName, trackedTime)); + RefreshContextualTimeago(); + RefreshStatistics(); + }, + function(xhr) + { + console.error(xhr); + } + ); + }, + function(xhr) + { + console.error(xhr); + } + ); +}); + +function RefreshStatistics() +{ + var nextXDays = $("#info-due-tasks").data("next-x-days"); + Grocy.Api.Get('tasks/get-current', + function(result) + { + var dueCount = 0; + var overdueCount = 0; + var now = moment(); + var nextXDaysThreshold = moment().add(nextXDays, "days"); + result.forEach(element => { + var date = moment(element.due); + if (date.isBefore(now)) + { + overdueCount++; + } + else if (date.isBefore(nextXDaysThreshold)) + { + dueCount++; + } + }); + + $("#info-due-tasks").text(Pluralize(dueCount, L('#1 task is due to be done within the next #2 days', dueCount, nextXDays), L('#1 tasks are due to be done within the next #2 days', dueCount, nextXDays))); + $("#info-overdue-tasks").text(Pluralize(overdueCount, L('#1 task is overdue to be done', overdueCount), L('#1 tasks are overdue to be done', overdueCount))); + }, + function(xhr) + { + console.error(xhr); + } + ); +} + +RefreshStatistics(); diff --git a/routes.php b/routes.php index 4ccb4fde..f6c36585 100644 --- a/routes.php +++ b/routes.php @@ -53,6 +53,10 @@ $app->group('', function() $this->get('/batteries', '\Grocy\Controllers\BatteriesController:BatteriesList'); $this->get('/battery/{batteryId}', '\Grocy\Controllers\BatteriesController:BatteryEditForm'); + // Task routes + $this->get('/tasks', '\Grocy\Controllers\TasksController:Overview'); + $this->get('/task/{taskId}', '\Grocy\Controllers\TasksController:TaskEditForm'); + // OpenAPI routes $this->get('/api', '\Grocy\Controllers\OpenApiController:DocumentationUi'); $this->get('/manageapikeys', '\Grocy\Controllers\OpenApiController:ApiKeysList'); @@ -102,6 +106,9 @@ $app->group('/api', function() $this->get('/batteries/track-charge-cycle/{batteryId}', '\Grocy\Controllers\BatteriesApiController:TrackChargeCycle'); $this->get('/batteries/get-battery-details/{batteryId}', '\Grocy\Controllers\BatteriesApiController:BatteryDetails'); $this->get('/batteries/get-current', '\Grocy\Controllers\BatteriesApiController:Current'); + + // Tasks + $this->get('/tasks/get-current', '\Grocy\Controllers\TasksApiController:Current'); })->add(new ApiKeyAuthMiddleware($appContainer, $appContainer->LoginControllerInstance->GetSessionCookieName(), $appContainer->ApiKeyHeaderName)) ->add(JsonMiddleware::class) ->add(new CorsMiddleware([ diff --git a/services/DemoDataGeneratorService.php b/services/DemoDataGeneratorService.php index 88b3b743..f86103e3 100644 --- a/services/DemoDataGeneratorService.php +++ b/services/DemoDataGeneratorService.php @@ -87,6 +87,12 @@ class DemoDataGeneratorService extends BaseService INSERT INTO batteries (name, description, used_in, charge_interval_days) VALUES ('{$localizationService->Localize('Battery')}3', '{$localizationService->Localize('Warranty ends')} 2022', '{$localizationService->Localize('Heat remote control')}', 60); --3 INSERT INTO batteries (name, description, used_in, charge_interval_days) VALUES ('{$localizationService->Localize('Battery')}4', '{$localizationService->Localize('Warranty ends')} 2028', '{$localizationService->Localize('Heat remote control')}', 60); --4 + INSERT INTO task_categories (name) VALUES ('{$localizationService->Localize('Home')}'); --1 + INSERT INTO task_categories (name) VALUES ('{$localizationService->Localize('Life')}'); --2 + INSERT INTO task_categories (name) VALUES ('{$localizationService->Localize('Projects')}'); --3 + + INSERT INTO tasks (name, category_id, due) VALUES ('{$localizationService->Localize('Repair the garage door')}', 1, date(datetime('now', 'localtime'), '+14 day')); + INSERT INTO migrations (migration) VALUES (-1); "; diff --git a/services/TasksService.php b/services/TasksService.php new file mode 100644 index 00000000..070f8a49 --- /dev/null +++ b/services/TasksService.php @@ -0,0 +1,18 @@ +DatabaseService->ExecuteDbQuery($sql)->fetchAll(\PDO::FETCH_OBJ); + } + + private function TaskExists($taskId) + { + $taskRow = $this->Database->tasks()->where('id = :1', $taskId)->fetch(); + return $taskRow !== null; + } +} diff --git a/views/components/datetimepicker.blade.php b/views/components/datetimepicker.blade.php index 4f6f10e4..e5308d2d 100644 --- a/views/components/datetimepicker.blade.php +++ b/views/components/datetimepicker.blade.php @@ -2,11 +2,13 @@ @endpush +@php if(!isset($isRequired)) { $isRequired = true; } @endphp +
- {{ $L('Recipes') }} +