From 4eac3ebe0e92e7eef1290980b5ece9d1f9d11c06 Mon Sep 17 00:00:00 2001 From: Matthieu Lamalle Date: Wed, 1 Jul 2020 15:54:52 +0200 Subject: [PATCH 01/10] Time Tracking --- src/schedule-2.0/assets/js/timer.js | 609 +++++++++++++++ src/schedule-2.0/config/routes.yaml | 20 + .../src/Controller/TimerController.php | 176 +++++ src/schedule-2.0/src/Entity/Task.php | 41 +- src/schedule-2.0/src/Entity/Timer.php | 142 ++++ src/schedule-2.0/src/Form/TimerType.php | 99 +++ .../templates/Timer/edit.html.twig | 110 +++ .../templates/Timer/list.html.twig | 693 ++++++++++++++++++ src/schedule-2.0/templates/base.html.twig | 6 + 9 files changed, 1894 insertions(+), 2 deletions(-) create mode 100644 src/schedule-2.0/assets/js/timer.js create mode 100644 src/schedule-2.0/src/Controller/TimerController.php create mode 100644 src/schedule-2.0/src/Entity/Timer.php create mode 100644 src/schedule-2.0/src/Form/TimerType.php create mode 100644 src/schedule-2.0/templates/Timer/edit.html.twig create mode 100644 src/schedule-2.0/templates/Timer/list.html.twig diff --git a/src/schedule-2.0/assets/js/timer.js b/src/schedule-2.0/assets/js/timer.js new file mode 100644 index 0000000..95638e4 --- /dev/null +++ b/src/schedule-2.0/assets/js/timer.js @@ -0,0 +1,609 @@ + + +/* Creates a new Task object. */ +function Task(id ,name, description) { + this._name = name; + this._taskid = id; + this._description = description; + this._state = Task.State.STOPPED; + this._timeSpentInPreviousIterations = 0; + this._startDate = 0; + this._endDate = 0; + this._currentIterationStartTime = 0; + this._customTimeSpent = 0; + this._onChange = null; +} + +/* Possible task states. */ +Task.State = { + STOPPED: "stopped", + RUNNING: "running" +} + +Task.prototype = { + /* Returns the task name. */ + getName: function() { + return this._name; + }, + + /* Returns the task description. */ + getDescription: function() { + if (this._description == "NaN") { + this._description = "" + } + return this._description; + }, + + /* Returns the task state. */ + getState: function() { + return this._state; + }, + + /* Is the task stopped? */ + isStopped: function() { + return this._state == Task.State.STOPPED; + }, + + /* Is the task running? */ + isRunning: function() { + return this._state == Task.State.RUNNING; + }, + + /* + * Sets the "onChange" event handler. The "onChange" event is fired when the + * task is started, stopped, or reset. + */ + setOnChange: function(onChange) { + this._onChange = onChange; + }, + + /* + * Returns the time spent on the task in the current work iteration. Works + * correctly only when the task is running. + */ + _getCurrentIterationTime: function() { + return (new Date).getTime() - this._currentIterationStartTime; + }, + + /* + * Returns the total time spent on the task. This includes time spent in + * the current work iteration if the task is running. + */ + getTimeSpent: function() { + var result = this._timeSpentInPreviousIterations; + if (this._state == Task.State.RUNNING) { + result += this._getCurrentIterationTime(); + } + return result; + }, + + /* Calls the "onChange" event handler if set. */ + _callOnChange: function() { + if (typeof this._onChange == "function") { + this._onChange(); + } + }, + + /* Starts a new task work iteration. */ + start: function() { + if (this._state == Task.State.RUNNING) { return }; + if (this._startDate == 0) {this._startDate = new Date()} + this._state = Task.State.RUNNING; + this._currentIterationStartTime = (new Date).getTime(); + this._callOnChange(); + }, + + /* Stops the current task work iteration. */ + stop: function() { + if (this._state == Task.State.STOPPED) { return }; + + this._state = Task.State.STOPPED; + this._timeSpentInPreviousIterations += this._getCurrentIterationTime(); + this._currentIterationStartTime = 0; + this._endDate = new Date(); + this._callOnChange(); + }, + + /* Stops the current task work iteration and resets the time data. */ + reset: function() { + this.stop(); + this._timeSpentInPreviousIterations = 0; + this._callOnChange(); + }, + save: function(task){ + console.log(task) + + $.ajax({ + type: "POST", + data: { + taskname: task._name, + taskid: task._taskid, + description: task._description, + start: task._startDate, + end: task._endDate, + duration: task._timeSpentInPreviousIterations, + }, + url: "{{ path('app_timer_create') }}", + success: function (response) { + response=JSON.parse(response); + if(response.return=="KO") { + $("#dataTable_wrapper").append("
"+response.error+"
"); + }else{ + location.reload(); + } + } + }); + }, + /* Serializes the task into a string. */ + serialize: function() { + /* + * Originally, I wanted to use "toSource" and "eval" for serialization and + * deserialization, but "toSource" is not supported by WebKit, so I resorted + * to ugly hackery... + */ + return [ + encodeURIComponent(this._name), + this._state, + this._timeSpentInPreviousIterations, + this._currentIterationStartTime, + this._startDate, + this._endDate, + this._taskid, + this._description, + ].join("&"); + }, + + /* Deserializes the task from a string. */ + deserialize: function(serialized) { + var parts = serialized.split("&"); + this._name = decodeURIComponent(parts[0]); + this._state = parts[1]; + this._timeSpentInPreviousIterations = parseInt(parts[2]); + this._currentIterationStartTime = parseInt(parts[3]); + this._startDate = parseInt(parts[4]); + this._endDate = parseInt(parts[5]); + this._taskid = parseInt(parts[6]); + this._description = parseInt(parts[7]); + + } +} + +/* ===== Tasks ===== */ + +/* The Tasks class represents a list of tasks. */ + +/* Creates a new Tasks object. */ +function Tasks() { + this._tasks = []; + + this._onAdd = null; + this._onRemove = null; +} + +Tasks.prototype = { + /* + * Sets the "onAdd" event handler. The "onAdd" event is fired when a task + * is added to the list. + */ + setOnAdd: function(onAdd) { + this._onAdd = onAdd; + }, + + /* + * Sets the "onRemove" event handler. The "onRemove" event is fired when a + * task is removed from the list. + */ + setOnRemove: function(onRemove) { + this._onRemove = onRemove; + }, + + /* Returns the length of the task list. */ + length: function() { + return this._tasks.length + }, + + /* + * Returns index-th task in the list, or "undefined" if the index is out of + * bounds. + */ + get: function(index) { + return this._tasks[index]; + }, + + /* + * Calls the callback function for each task in the list. The function is + * called with three parameters - the task, its index and the task list + * object. This is modeled after "Array.forEach" in JavaScript 1.6. + */ + forEach: function(callback) { + for (var i = 0; i < this._tasks.length; i++) { + callback(this._tasks[i], i, this); + } + }, + + /* Calls the "onAdd" event handler if set. */ + _callOnAdd: function(task) { + if (typeof this._onAdd == "function") { + this._onAdd(task); + } + }, + + /* Adds a new task to the end of the list. */ + add: function(task) { + this._tasks.push(task); + this._callOnAdd(task); + }, + + /* Calls the "onRemove" event handler if set. */ + _callOnRemove: function(index) { + if (typeof this._onRemove == "function") { + this._onRemove(index); + } + }, + + /* + * Removes index-th task from the list. Does not do anything if the index + * is out of bounds. + */ + remove: function(index) { + this._callOnRemove(index); + this._tasks.splice(index, 1); + }, + + + + /* Serializes the list of tasks into a string. */ + serialize: function() { + var serializedTasks = []; + this.forEach(function(task) { + serializedTasks.push(task.serialize()); + }); + return serializedTasks.join("|"); + }, + + /* Deserializes the list of tasks from a string. */ + deserialize: function(serialized) { + /* + * Repeatedly use "remove" so the "onRemove" event is triggered. Do the same + * with the "add" method below. + */ + while (this._tasks.length > 0) { + this.remove(0); + } + + var serializedTasks = serialized.split("|"); + for (var i = 0; i < serializedTasks.length; i++) { + var task = new Task(0 ,"", ""); + task.deserialize(serializedTasks[i]); + this.add(task); + } + } +} + +/* ===== Extensions ===== */ + +/* + * Pads this string with another string on the left until the resulting string + * has specified length. If the padding string has more than one character, the + * resulting string may be longer than desired (the padding string is not + * truncated and it is only prepended as a whole). Bad API, I know, but it's + * good enough for me. + */ +String.prototype.pad = function(length, padding) { + var result = this; + while (result.length < length) { + result = padding + result; + } + return result; +} + +/* ===== Task List + DOM Storage ===== */ + +/* The list of tasks. */ +var tasks = new Tasks(); + +/* The last value of the serialized task list string. */ +var lastSerializedTasksString; + +/* + * The key under which the serialized task list string is stored in the DOM + * Storage. + */ +var TASKS_DOM_STORAGE_KEY = "timerTasks"; + +/* + * Returns DOM Storage used by the application, or "null" if the browser does + * not support DOM Storage. + */ +function getStorage() { + /* + * HTML 5 says that the persistent storage is available in the + * "window.localStorage" attribute, however Firefox implements older version + * of the proposal, which uses "window.globalStorage" indexed by the domain + * name. We deal with this situation here as gracefully as possible (i.e. + * without concrete browser detection and with forward compatibility). + * + * For more information, see: + * + * http://www.whatwg.org/specs/web-apps/current-work/#storage + * https://developer.mozilla.org/En/DOM/Storage + */ + if (window.localStorage !== undefined) { + return window.localStorage; + } else if (window.globalStorage !== undefined) { + return window.globalStorage[location.hostname]; + } else { + return null; + } +} + +/* + * Saves the task list into a DOM Storage. Also updates the value of the + * "lastSerializedTasksString" variable. + */ +function saveTasks() { + var serializedTasksString = tasks.serialize(); + getStorage()[TASKS_DOM_STORAGE_KEY] = serializedTasksString; + lastSerializedTasksString = serializedTasksString; +} + +/* + * Loads the serialized task list string from the DOM Storage. Returns + * "undefined" if the storage does not contain the string (this happens when + * running the application for the first time). + */ +function loadSerializedTasksString() { + var storedValue = getStorage()[TASKS_DOM_STORAGE_KEY]; + /* + * The spec says "null" should be returned when the key is not found, but some + * browsers return "undefined" instead. Maybe it was in some earlier version + * of the spec (I didn't bother to check). + */ + if (storedValue !== null && storedValue !== undefined && storedValue.length > 0) { + /* + * The values retrieved from "globalStorage" use one more level of + * indirection. + */ + return (window.localStorage === undefined) ? storedValue.value : storedValue; + } else { + return undefined; + } +} + +/* + * Loads the task list from the DOM Storage. Also updates the value of the + * "lastSerializedTasksString" variable. + */ +function loadTasks() { + var serializedTasksString = loadSerializedTasksString(); + if (serializedTasksString !== undefined) { + tasks.deserialize(serializedTasksString); + lastSerializedTasksString = serializedTasksString; + } +} + +/* + * Was the task list changed outside of the application? Detects the change + * by comparing the current serialized task list string in the DOM Storage + * with a kept old value. + */ +function tasksHaveChangedOutsideApplication() { + var serializedTasksString = loadSerializedTasksString(); + if (serializedTasksString != lastSerializedTasksString) { + return true + } + return false; +} + +/* ===== View ===== */ + +/* Some time constants. */ +var MILISECONDS_IN_SECOND = 1000; +var MILISECONDS_IN_MINUTE = 60 * MILISECONDS_IN_SECOND; +var MINUTES_IN_HOUR = 60; + +/* Formats the time in the H:MM format. */ +function formatTime(time) { + var timeInMinutes = time / MILISECONDS_IN_MINUTE; + var hours = Math.floor(timeInMinutes / MINUTES_IN_HOUR); + var minutes = Math.floor(timeInMinutes - hours * MINUTES_IN_HOUR); + return hours + ":" + String(minutes).pad(2, "0"); +} + +/* + * Computes the URL of the image in the start/stop link according to the task + * state. + */ +function computeStartStopLinkImageUrl(state) { + switch (state) { + case Task.State.STOPPED: + return "fa-play"; + case Task.State.RUNNING: + return "fa-stop"; + default: + throw "Invalid task state." + } +} + +/* + * Builds the HTML element of the row in the task table corresponding to the + * specified task and index. + */ +function buildTaskRow(task, index) { + var result = $(""); + + var startStopLink = $( + "" + + "" + + ); + startStopLink.click(function() { + switch (task.getState()) { + case Task.State.STOPPED: + task.start(); + break; + case Task.State.RUNNING: + task.stop(); + break; + default: + throw "Invalid task state." + } + saveTasks(); + return false; + }); + + var resetLink = $( + "" + + "" + ); + resetLink.click(function() { + task.reset(); + saveTasks(); + return false; + }); + + var deleteLink = $( + "" + + "" + + ); + deleteLink.click(function() { + if (confirm("Do you really want to delete task \"" + task.getName() + "\"?")) { + tasks.remove(index); + saveTasks(); + } + return false; + }); + + var saveLink = $( + "" + + "" + ); + saveLink.click(function() { + tasks.remove(index); + saveTasks(); + task.save(task); + return false; + }); + result + .addClass("state-" + task.getState()) + .append($("").text(task.getName())) + .append($("").text(task.getDescription())) + .append($("").text(formatTime(task.getTimeSpent()))) + .append($("") + .append(startStopLink) + .append("  ") + .append(resetLink) + .append("  ") + .append(saveLink) + .append("    ") + .append(deleteLink) + ); + + return result; +} + +/* Finds row with the specified index in the task table. */ +function findRowWithIndex(index) { + return $("#task-table").find("tr").slice(1).eq(index); +} + +/* + * Updates the row in the task table corresponding to a task according to + * its state. + */ +function updateTaskRow(row, task) { + if (task.isStopped()) { + row.removeClass("state-running"); + row.addClass("state-stopped"); + } else if (task.isRunning()) { + row.removeClass("state-stopped"); + row.addClass("state-running"); + } + + row.find(".task-time").text(formatTime(task.getTimeSpent())) + + row.find(".start-stop-link i").removeClass(); + row.find(".start-stop-link i").addClass("fa "+computeStartStopLinkImageUrl(task.getState())); +} + +/* ===== Initialization ===== */ + +/* Initializes event handlers on the task list. */ +function initializeTasksEventHandlers() { + tasks.setOnAdd(function(task) { + var row = buildTaskRow(task, tasks.length() - 1); + $("#task-table").append(row); + task.setOnChange(function() { + updateTaskRow(row, task); + }); + }); + + tasks.setOnRemove(function(index) { + findRowWithIndex(index).remove(); + if (tasks.length() == 1 ) { + $( "#task-table" ).css({"display":"none"}); + } + }); +} + +/* Initializes GUI event handlers. */ +function initializeGuiEventHandlers() { + $( "#addtimer" ).click(function() { + displayTaskAdd(); + }); + + $( "#timer-desc" ).keypress(function( event ) { + if ( event.which == 13 ) { + event.preventDefault(); + displayTaskAdd(); + } + }); + +} +function displayTaskAdd() { + $( "#task-table" ).css({"display":"inline"}); + var taskName = $("#timer-task option:selected").text(); + var taskId = $("#timer-task option:selected").val(); + var taskDesc = $("#timer-desc").val(); + + var task = new Task(taskId, taskName, taskDesc); + tasks.add(task); + saveTasks(); + $("#timer-desc").val(""); +} +/* + * Initializes a timer used to update task times and detect changes in the + * data stored in the DOM Storage. + */ +function initializeTimer() { + setInterval(function() { + tasks.forEach(function(task, index) { + updateTaskRow(findRowWithIndex(index), task); + }); + + if (tasksHaveChangedOutsideApplication()) { + loadTasks(); + } + }, 10 * MILISECONDS_IN_SECOND); +} + +/* Initializes the application. */ +$(document).ready(function(){ + try { + if (!getStorage()) { + alert("Timer requires a browser with DOM Storage support, such as Firefox 3+ or Safari 4+."); + return; + } + } catch (e) { + alert("Timer does not work with file: URLs in Firefox."); + return; + } + + initializeTasksEventHandlers(); + loadTasks(); + initializeGuiEventHandlers(); + initializeTimer(); +}); + + diff --git a/src/schedule-2.0/config/routes.yaml b/src/schedule-2.0/config/routes.yaml index 079aadb..d088699 100644 --- a/src/schedule-2.0/config/routes.yaml +++ b/src/schedule-2.0/config/routes.yaml @@ -347,6 +347,26 @@ app_validationholiday_activeholiday: path: /validator/validateholiday/activeholiday defaults: { _controller: App\Controller\ValidationController:activeholiday } +#== Timer ==================================================================================================== +app_timer: + path: /user/timer + defaults: { _controller: App\Controller\TimerController:list } + +app_timer_submit: + path: /user/timer/submit + defaults: { _controller: App\Controller\TimerController:submit } + +app_timer_create: + path: /user/timer/create + defaults: { _controller: App\Controller\TimerController:create } + +app_timer_update: + path: /user/timer/update/{id} + defaults: { _controller: App\Controller\TimerController:update } + +app_timer_delete: + path: /user/timer/delete/{id} + defaults: { _controller: App\Controller\TimerController:delete } #== Customer ====================================================================================================== app_customer_report: diff --git a/src/schedule-2.0/src/Controller/TimerController.php b/src/schedule-2.0/src/Controller/TimerController.php new file mode 100644 index 0000000..effe14c --- /dev/null +++ b/src/schedule-2.0/src/Controller/TimerController.php @@ -0,0 +1,176 @@ +getDoctrine()->getManager(); + + $iduser = $this->get("session")->get("iduser"); + $user = $em->getRepository("App:User")->find($iduser); + $tasks = $em->getRepository("App:Task")->findAll(); + $timers = $em->getRepository("App:Timer")->findBy(["user"=>$iduser]); + + return $this->render($this->render.'list.html.twig',[ + "useheader" => true, + "usesidebar" => true, + "user" => $user, + "tasks" => $tasks, + "timers" => $timers, + ]); + } + + public function create(Request $request) + { + // Initialisation de l'enregistrement + $em = $this->getDoctrine()->getManager(); + $iduser = $this->get("session")->get("iduser"); + $user = $em->getRepository("App:User")->find($iduser); + $taskid = $request->request->get('taskid'); + $description = $request->request->get('description'); + $task = $em->getRepository("App:Task")->find($taskid); + $start = \DateTime::createFromFormat('D M d Y H:i:s e+',$request->request->get('start')); + $end = \DateTime::createFromFormat('D M d Y H:i:s e+',$request->request->get('end')); + $duration = new \DateTime(date("H:i:s", ($request->request->get('duration')/1000))); + $duration->sub(new \DateInterval('PT1H')); + + $timer = new Entity(); + $timer->setUser($user); + $timer->setTask($task); + $timer->setStart($start); + $timer->setEnd($end); + $timer->setDuration($duration); + $timer->setDescription($description); + $em->persist($timer); + $em->flush(); + + $output=["return"=>"OK"]; + return new Response(json_encode($output)); + } + public function submit(Request $request) + { + // Initialisation de l'enregistrement + $em = $this->getDoctrine()->getManager(); + $data = new Entity(); + + // Création du formulaire + $form = $this->createForm(Form::class,$data,array("mode"=>"submit")); + + // Récupération des data du formulaire + $form->handleRequest($request); + + // Sur erreur + $this->getErrorForm(null,$form,$request,$data,"submit"); + + // Sur validation + if ($form->get('submit')->isClicked() && $form->isValid()) { + $data = $form->getData(); + $em->persist($data); + $em->flush(); + + // Retour à la liste + return $this->redirectToRoute($this->route); + } + + // Affichage du formulaire + return $this->render($this->render.'edit.html.twig', [ + 'useheader' => true, + 'usesidebar' => true, + $this->data => $data, + 'mode' => 'submit', + 'form' => $form->createView() + ]); + } + + public function update($id,Request $request) + { + // Initialisation de l'enregistrement + $em = $this->getDoctrine()->getManager(); + $data=$em->getRepository($this->entity)->find($id); + + // Création du formulaire + $form = $this->createForm(Form::class,$data,array("mode"=>"update")); + + // Récupération des data du formulaire + $form->handleRequest($request); + + // Sur erreur + $this->getErrorForm(null,$form,$request,$data,"update"); + + // Sur validation + if ($form->get('submit')->isClicked() && $form->isValid()) { + $data = $form->getData(); + $em->persist($data); + $em->flush(); + + // Retour à la liste + return $this->redirectToRoute($this->route); + } + + + return $this->render($this->render.'edit.html.twig', [ + 'useheader' => true, + 'usesidebar' => true, + $this->data => $data, + 'mode' => 'update', + 'form' => $form->createView() + ]); + + } + public function delete($id, Request $request) + { + // Initialisation de l'enregistrement + $em = $this->getDoctrine()->getManager(); + $data=$em->getRepository($this->entity)->find($id); + + // Controle avant suppression + $error=false; + if($error) + return $this->redirectToRoute($this->route."_update",["id"=>$id]); + else { + try { + $em->remove($data); + $em->flush(); + } + catch(\Doctrine\DBAL\DBALException $e) { + // Création du formulaire + $this->get('session')->getFlashBag()->add('error', 'Impossible de supprimer cet enregistrement'); + return $this->redirectToRoute($this->route."_update",["id"=>$id]); + } + + // Retour à la liste + return $this->redirectToRoute($this->route); + } + } + protected function getErrorForm($id,$form,$request,$data,$mode) { + if ($form->get('submit')->isClicked()&&$mode=="delete") { + } + + if ($form->get('submit')->isClicked() && $mode=="submit") { + } + + if ($form->get('submit')->isClicked() && !$form->isValid()) { + $this->get('session')->getFlashBag()->clear(); + + $errors = $form->getErrors(); + foreach( $errors as $error ) { + $request->getSession()->getFlashBag()->add("error", $error->getMessage()); + } + } + } +} diff --git a/src/schedule-2.0/src/Entity/Task.php b/src/schedule-2.0/src/Entity/Task.php index ff59675..3a9f470 100644 --- a/src/schedule-2.0/src/Entity/Task.php +++ b/src/schedule-2.0/src/Entity/Task.php @@ -61,12 +61,17 @@ class Task */ private $events; + /** + * @ORM\OneToMany(targetEntity="Timer", mappedBy="task", cascade={"persist"}, orphanRemoval=false) + */ + private $timers; + /** * @ORM\OneToMany(targetEntity="Penalty", mappedBy="task", cascade={"persist"}, orphanRemoval=false) */ private $penaltys; - /** + /** * Calculate Displayname */ public function getDisplayname(): ?string @@ -74,7 +79,7 @@ class Task return $this->project->getCustomer()->getName()."-".$this->project->getName()."-".$this->name; } - /** + /** * Calculate Duration */ public function getDuration($end): ?string @@ -90,6 +95,7 @@ class Task public function __construct() { $this->events = new ArrayCollection(); + $this->timers = new ArrayCollection(); $this->penaltys = new ArrayCollection(); } @@ -191,6 +197,24 @@ class Task return $this; } + /** + * @return Collection|Timer[] + */ + public function getTimers(): Collection + { + return $this->tasks; + } + + public function addTimer(Timer $timer): self + { + if (!$this->timers->contains($timer)) { + $this->timers[] = $timer; + $timer->setTask($this); + } + + return $this; + } + public function removeEvent(Event $event): self { if ($this->events->contains($event)) { @@ -235,4 +259,17 @@ class Task return $this; } + public function removeTimer(Timer $timer): self + { + if ($this->timers->contains($timer)) { + $this->timers->removeElement($timer); + // set the owning side to null (unless already changed) + if ($timer->getTask() === $this) { + $timer->setTask(null); + } + } + + return $this; + } + } diff --git a/src/schedule-2.0/src/Entity/Timer.php b/src/schedule-2.0/src/Entity/Timer.php new file mode 100644 index 0000000..2d5ace0 --- /dev/null +++ b/src/schedule-2.0/src/Entity/Timer.php @@ -0,0 +1,142 @@ +start = new \DateTime(); + $this->end = new \DateTime(); + $this->duration = new \DateTime(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getStart(): ?\DateTimeInterface + { + return $this->start; + } + + public function setStart(\DateTimeInterface $start): self + { + $this->start = $start; + + return $this; + } + + public function getEnd(): ?\DateTimeInterface + { + return $this->end; + } + + public function setEnd(\DateTimeInterface $end): self + { + $this->end = $end; + + return $this; + } + + public function getDuration(): ?\DateTimeInterface + { + return $this->duration; + } + + public function setDuration(\DateTimeInterface $duration): self + { + $this->duration = $duration; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + + return $this; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): self + { + $this->user = $user; + + return $this; + } + + public function getTask(): ?Task + { + return $this->task; + } + + public function setTask(?Task $task): self + { + $this->task = $task; + + return $this; + } + +} diff --git a/src/schedule-2.0/src/Form/TimerType.php b/src/schedule-2.0/src/Form/TimerType.php new file mode 100644 index 0000000..f53771f --- /dev/null +++ b/src/schedule-2.0/src/Form/TimerType.php @@ -0,0 +1,99 @@ +add('submit', + SubmitType::class, [ + "label" => "Valider", + "attr" => ["class" => "btn btn-success no-print"], + ] + ); + $builder->add('task', + EntityType::class, [ + "label" => "Tâche", + "class" => "App:Task", + "choice_label" => function ($task) { + return $task->getDisplayname();}, + + "disabled" => false, + "required" => true, + "multiple" => false, + "placeholder" => "Selectionner une Tâche", + ] + ); + + $builder->add('description', + TextType::class, [ + "label" => "Description" + ] + ); + + $builder->add('start', + DateTimeType::class, [ + "label" =>"Début", + "date_widget" => "single_text", + "time_widget" => "single_text", + "format" => "yyyy-MM-dd HH:mm", + ] + ); + + $builder->add('end', + DateTimeType::class, [ + "label" =>"Fin", + "date_widget" => "single_text", + "time_widget" => "single_text", + "format" => "yyyy-MM-dd HH:mm", + ] + ); + + $builder->addEventListener( + + ); + $builder->add('duration', + TimeType::class, [ + "label" =>"Durée", + "widget" => "single_text", + ] + ); + + + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults(array( + 'data_class' => 'App\Entity\Timer', + 'mode' => 'string', + )); + } +} diff --git a/src/schedule-2.0/templates/Timer/edit.html.twig b/src/schedule-2.0/templates/Timer/edit.html.twig new file mode 100644 index 0000000..a0a8403 --- /dev/null +++ b/src/schedule-2.0/templates/Timer/edit.html.twig @@ -0,0 +1,110 @@ +{% extends "base.html.twig" %} + +{% block localstyle %} + td { + padding:5px !important; + } +{% endblock %} + +{% block body %} +{{ form_start(form) }} +

+ {% if mode=="update" %} + Modification TIMER + {% elseif mode=="submit" %} + Création TIMER + {% endif %} +

+ + + {{ form_widget(form.submit) }} + +
Annuler + + {% if mode=="update" %} + + + Supprimer + + {% endif %} + +

+ + {% if app.session.flashbag.has('error') %} +
+ Erreur
+ {% for flashMessage in app.session.flashbag.get('error') %} + {{ flashMessage }}
+ {% endfor %} +
+ {% endif %} + + {% if app.session.flashbag.has('notice') %} +
+ Information
+ {% for flashMessage in app.session.flashbag.get('notice') %} + {{ flashMessage }}
+ {% endfor %} +
+ {% endif %} +
+
+ Informations +
+ +
+ {{ form_row(form.task) }} + {{ form_row(form.description) }} + {{ form_row(form.start) }} + {{ form_row(form.end) }} + {{ form_row(form.duration) }} +
+
+{{ form_end(form) }} + + +{% endblock %} + +{% block localjavascript %} +$("#timer_task").addClass("select2entity"); +/* + * Pads this string with another string on the left until the resulting string + * has specified length. If the padding string has more than one character, the + * resulting string may be longer than desired (the padding string is not + * truncated and it is only prepended as a whole). Bad API, I know, but it's + * good enough for me. + */ +String.prototype.pad = function(length, padding) { + var result = this; + while (result.length < length) { + result = padding + result; + } + return result; +} +/* Some time constants. */ +var MILISECONDS_IN_SECOND = 1000; +var MILISECONDS_IN_MINUTE = 60 * MILISECONDS_IN_SECOND; +var MINUTES_IN_HOUR = 60; + +/* Formats the time in the H:MM format. */ +function formatTime(time) { + var timeInMinutes = time / MILISECONDS_IN_MINUTE; + var hours = Math.floor(timeInMinutes / MINUTES_IN_HOUR); + var minutes = Math.floor(timeInMinutes - hours * MINUTES_IN_HOUR); + return String(hours).pad(2, "0") + ":" + String(minutes).pad(2, "0"); +} + +$("#timer_start_time,#timer_end_time").change(function(){ + console.log($("#timer_start_date").val() +"T"+$("#timer_start_time").val()+":00Z") + console.log($("#timer_end_date").val()+"T"+$("#timer_end_time").val()+":00Z") + var start = Date.parse($("#timer_start_date").val() +"T"+$("#timer_start_time").val()+":00Z"); + var end = Date.parse($("#timer_end_date").val()+"T"+$("#timer_end_time").val()+":00Z"); + var diff = end - start; + $("#timer_duration").val(formatTime(diff)) +}) + +{% endblock %} + diff --git a/src/schedule-2.0/templates/Timer/list.html.twig b/src/schedule-2.0/templates/Timer/list.html.twig new file mode 100644 index 0000000..8c82319 --- /dev/null +++ b/src/schedule-2.0/templates/Timer/list.html.twig @@ -0,0 +1,693 @@ +{% extends "base.html.twig" %} + +{% block localstyle %} +#timer-task { + width:300px; + } +#timer-desc { + width:300px; +} +{% endblock %} + +{% block body %} +

+SUIVI HORAIRE +

+
+ +
+ Lancer un Timer : + + + + + +
+
+ +
+ +
+
+ +Créer un Timer +

+
+ + + + + + + + + + + + + {%for timer in timers %} + + + + + + + + + {% endfor %} + +
TâcheDescriptionDébutFinDuréeActions
{{ timer.task.displayname }}{{ timer.description }}{{ timer.start|date("d/m/Y H:i") }}{{ timer.end|date("d/m/Y H:i") }}{{ timer.duration|date("H:i") }} + + + +    + + + +
+
+{% endblock %} + +{% block localjavascript %} + + +/* Creates a new Task object. */ +function Task(id ,name, description) { + this._name = name; + this._taskid = id; + this._description = description; + this._state = Task.State.STOPPED; + this._timeSpentInPreviousIterations = 0; + this._startDate = 0; + this._endDate = 0; + this._currentIterationStartTime = 0; + this._customTimeSpent = 0; + this._onChange = null; +} + +/* Possible task states. */ +Task.State = { + STOPPED: "stopped", + RUNNING: "running" +} + +Task.prototype = { + /* Returns the task name. */ + getName: function() { + return this._name; + }, + + /* Returns the task description. */ + getDescription: function() { + if (this._description == "NaN") { + this._description = "" + } + return this._description; + }, + + /* Returns the task state. */ + getState: function() { + return this._state; + }, + + /* Is the task stopped? */ + isStopped: function() { + return this._state == Task.State.STOPPED; + }, + + /* Is the task running? */ + isRunning: function() { + return this._state == Task.State.RUNNING; + }, + + /* + * Sets the "onChange" event handler. The "onChange" event is fired when the + * task is started, stopped, or reset. + */ + setOnChange: function(onChange) { + this._onChange = onChange; + }, + + /* + * Returns the time spent on the task in the current work iteration. Works + * correctly only when the task is running. + */ + _getCurrentIterationTime: function() { + return (new Date).getTime() - this._currentIterationStartTime; + }, + + /* + * Returns the total time spent on the task. This includes time spent in + * the current work iteration if the task is running. + */ + getTimeSpent: function() { + var result = this._timeSpentInPreviousIterations; + if (this._state == Task.State.RUNNING) { + result += this._getCurrentIterationTime(); + } + return result; + }, + + /* Calls the "onChange" event handler if set. */ + _callOnChange: function() { + if (typeof this._onChange == "function") { + this._onChange(); + } + }, + + /* Starts a new task work iteration. */ + start: function() { + if (this._state == Task.State.RUNNING) { return }; + if (this._startDate == 0) {this._startDate = new Date()} + this._state = Task.State.RUNNING; + this._currentIterationStartTime = (new Date).getTime(); + this._callOnChange(); + }, + + /* Stops the current task work iteration. */ + stop: function() { + if (this._state == Task.State.STOPPED) { return }; + + this._state = Task.State.STOPPED; + this._timeSpentInPreviousIterations += this._getCurrentIterationTime(); + this._currentIterationStartTime = 0; + this._endDate = new Date(); + this._callOnChange(); + }, + + /* Stops the current task work iteration and resets the time data. */ + reset: function() { + this.stop(); + this._timeSpentInPreviousIterations = 0; + this._callOnChange(); + }, + save: function(task){ + console.log(task) + + $.ajax({ + type: "POST", + data: { + taskname: task._name, + taskid: task._taskid, + description: task._description, + start: task._startDate, + end: task._endDate, + duration: task._timeSpentInPreviousIterations, + }, + url: "{{ path('app_timer_create') }}", + success: function (response) { + response=JSON.parse(response); + if(response.return=="KO") { + $("#dataTable_wrapper").append("
"+response.error+"
"); + }else{ + location.reload(); + } + } + }); + }, + /* Serializes the task into a string. */ + serialize: function() { + /* + * Originally, I wanted to use "toSource" and "eval" for serialization and + * deserialization, but "toSource" is not supported by WebKit, so I resorted + * to ugly hackery... + */ + return [ + encodeURIComponent(this._name), + this._state, + this._timeSpentInPreviousIterations, + this._currentIterationStartTime, + this._startDate, + this._endDate, + this._taskid, + this._description, + ].join("&"); + }, + + /* Deserializes the task from a string. */ + deserialize: function(serialized) { + var parts = serialized.split("&"); + this._name = decodeURIComponent(parts[0]); + this._state = parts[1]; + this._timeSpentInPreviousIterations = parseInt(parts[2]); + this._currentIterationStartTime = parseInt(parts[3]); + this._startDate = parseInt(parts[4]); + this._endDate = parseInt(parts[5]); + this._taskid = parseInt(parts[6]); + this._description = parseInt(parts[7]); + + } +} + +/* ===== Tasks ===== */ + +/* The Tasks class represents a list of tasks. */ + +/* Creates a new Tasks object. */ +function Tasks() { + this._tasks = []; + + this._onAdd = null; + this._onRemove = null; +} + +Tasks.prototype = { + /* + * Sets the "onAdd" event handler. The "onAdd" event is fired when a task + * is added to the list. + */ + setOnAdd: function(onAdd) { + this._onAdd = onAdd; + }, + + /* + * Sets the "onRemove" event handler. The "onRemove" event is fired when a + * task is removed from the list. + */ + setOnRemove: function(onRemove) { + this._onRemove = onRemove; + }, + + /* Returns the length of the task list. */ + length: function() { + return this._tasks.length + }, + + /* + * Returns index-th task in the list, or "undefined" if the index is out of + * bounds. + */ + get: function(index) { + return this._tasks[index]; + }, + + /* + * Calls the callback function for each task in the list. The function is + * called with three parameters - the task, its index and the task list + * object. This is modeled after "Array.forEach" in JavaScript 1.6. + */ + forEach: function(callback) { + for (var i = 0; i < this._tasks.length; i++) { + callback(this._tasks[i], i, this); + } + }, + + /* Calls the "onAdd" event handler if set. */ + _callOnAdd: function(task) { + if (typeof this._onAdd == "function") { + this._onAdd(task); + } + }, + + /* Adds a new task to the end of the list. */ + add: function(task) { + this._tasks.push(task); + this._callOnAdd(task); + }, + + /* Calls the "onRemove" event handler if set. */ + _callOnRemove: function(index) { + if (typeof this._onRemove == "function") { + this._onRemove(index); + } + }, + + /* + * Removes index-th task from the list. Does not do anything if the index + * is out of bounds. + */ + remove: function(index) { + this._callOnRemove(index); + this._tasks.splice(index, 1); + }, + + + + /* Serializes the list of tasks into a string. */ + serialize: function() { + var serializedTasks = []; + this.forEach(function(task) { + serializedTasks.push(task.serialize()); + }); + return serializedTasks.join("|"); + }, + + /* Deserializes the list of tasks from a string. */ + deserialize: function(serialized) { + /* + * Repeatedly use "remove" so the "onRemove" event is triggered. Do the same + * with the "add" method below. + */ + while (this._tasks.length > 0) { + this.remove(0); + } + + var serializedTasks = serialized.split("|"); + for (var i = 0; i < serializedTasks.length; i++) { + var task = new Task(0 ,"", ""); + task.deserialize(serializedTasks[i]); + this.add(task); + } + } +} + +/* ===== Extensions ===== */ + +/* + * Pads this string with another string on the left until the resulting string + * has specified length. If the padding string has more than one character, the + * resulting string may be longer than desired (the padding string is not + * truncated and it is only prepended as a whole). Bad API, I know, but it's + * good enough for me. + */ +String.prototype.pad = function(length, padding) { + var result = this; + while (result.length < length) { + result = padding + result; + } + return result; +} + +/* ===== Task List + DOM Storage ===== */ + +/* The list of tasks. */ +var tasks = new Tasks(); + +/* The last value of the serialized task list string. */ +var lastSerializedTasksString; + +/* + * The key under which the serialized task list string is stored in the DOM + * Storage. + */ +var TASKS_DOM_STORAGE_KEY = "timerTasks"; + +/* + * Returns DOM Storage used by the application, or "null" if the browser does + * not support DOM Storage. + */ +function getStorage() { + /* + * HTML 5 says that the persistent storage is available in the + * "window.localStorage" attribute, however Firefox implements older version + * of the proposal, which uses "window.globalStorage" indexed by the domain + * name. We deal with this situation here as gracefully as possible (i.e. + * without concrete browser detection and with forward compatibility). + * + * For more information, see: + * + * http://www.whatwg.org/specs/web-apps/current-work/#storage + * https://developer.mozilla.org/En/DOM/Storage + */ + if (window.localStorage !== undefined) { + return window.localStorage; + } else if (window.globalStorage !== undefined) { + return window.globalStorage[location.hostname]; + } else { + return null; + } +} + +/* + * Saves the task list into a DOM Storage. Also updates the value of the + * "lastSerializedTasksString" variable. + */ +function saveTasks() { + var serializedTasksString = tasks.serialize(); + getStorage()[TASKS_DOM_STORAGE_KEY] = serializedTasksString; + lastSerializedTasksString = serializedTasksString; +} + +/* + * Loads the serialized task list string from the DOM Storage. Returns + * "undefined" if the storage does not contain the string (this happens when + * running the application for the first time). + */ +function loadSerializedTasksString() { + var storedValue = getStorage()[TASKS_DOM_STORAGE_KEY]; + /* + * The spec says "null" should be returned when the key is not found, but some + * browsers return "undefined" instead. Maybe it was in some earlier version + * of the spec (I didn't bother to check). + */ + if (storedValue !== null && storedValue !== undefined && storedValue.length > 0) { + /* + * The values retrieved from "globalStorage" use one more level of + * indirection. + */ + return (window.localStorage === undefined) ? storedValue.value : storedValue; + } else { + return undefined; + } +} + +/* + * Loads the task list from the DOM Storage. Also updates the value of the + * "lastSerializedTasksString" variable. + */ +function loadTasks() { + var serializedTasksString = loadSerializedTasksString(); + if (serializedTasksString !== undefined) { + tasks.deserialize(serializedTasksString); + lastSerializedTasksString = serializedTasksString; + } +} + +/* + * Was the task list changed outside of the application? Detects the change + * by comparing the current serialized task list string in the DOM Storage + * with a kept old value. + */ +function tasksHaveChangedOutsideApplication() { + var serializedTasksString = loadSerializedTasksString(); + if (serializedTasksString != lastSerializedTasksString) { + return true + } + return false; +} + +/* ===== View ===== */ + +/* Some time constants. */ +var MILISECONDS_IN_SECOND = 1000; +var MILISECONDS_IN_MINUTE = 60 * MILISECONDS_IN_SECOND; +var MINUTES_IN_HOUR = 60; + +/* Formats the time in the H:MM format. */ +function formatTime(time) { + var timeInMinutes = time / MILISECONDS_IN_MINUTE; + var hours = Math.floor(timeInMinutes / MINUTES_IN_HOUR); + var minutes = Math.floor(timeInMinutes - hours * MINUTES_IN_HOUR); + return hours + ":" + String(minutes).pad(2, "0"); +} + +/* + * Computes the URL of the image in the start/stop link according to the task + * state. + */ +function computeStartStopLinkImageUrl(state) { + switch (state) { + case Task.State.STOPPED: + return "fa-play"; + case Task.State.RUNNING: + return "fa-stop"; + default: + throw "Invalid task state." + } +} + +/* + * Builds the HTML element of the row in the task table corresponding to the + * specified task and index. + */ +function buildTaskRow(task, index) { + var result = $(""); + + var startStopLink = $( + "" + + "" + + ); + startStopLink.click(function() { + switch (task.getState()) { + case Task.State.STOPPED: + task.start(); + break; + case Task.State.RUNNING: + task.stop(); + break; + default: + throw "Invalid task state." + } + saveTasks(); + return false; + }); + + var resetLink = $( + "" + + "" + ); + resetLink.click(function() { + task.reset(); + saveTasks(); + return false; + }); + + var deleteLink = $( + "" + + "" + + ); + deleteLink.click(function() { + if (confirm("Do you really want to delete task \"" + task.getName() + "\"?")) { + tasks.remove(index); + saveTasks(); + } + return false; + }); + + var saveLink = $( + "" + + "" + ); + saveLink.click(function() { + tasks.remove(index); + saveTasks(); + task.save(task); + return false; + }); + result + .addClass("state-" + task.getState()) + .append($("").text(task.getName())) + .append($("").text(task.getDescription())) + .append($("").text(formatTime(task.getTimeSpent()))) + .append($("") + .append(startStopLink) + .append("  ") + .append(resetLink) + .append("  ") + .append(saveLink) + .append("    ") + .append(deleteLink) + ); + + return result; +} + +/* Finds row with the specified index in the task table. */ +function findRowWithIndex(index) { + return $("#task-table").find("tr").slice(1).eq(index); +} + +/* + * Updates the row in the task table corresponding to a task according to + * its state. + */ +function updateTaskRow(row, task) { + if (task.isStopped()) { + row.removeClass("state-running"); + row.addClass("state-stopped"); + } else if (task.isRunning()) { + row.removeClass("state-stopped"); + row.addClass("state-running"); + } + + row.find(".task-time").text(formatTime(task.getTimeSpent())) + + row.find(".start-stop-link i").removeClass(); + row.find(".start-stop-link i").addClass("fa "+computeStartStopLinkImageUrl(task.getState())); +} + +/* ===== Initialization ===== */ + +/* Initializes event handlers on the task list. */ +function initializeTasksEventHandlers() { + tasks.setOnAdd(function(task) { + var row = buildTaskRow(task, tasks.length() - 1); + $("#task-table").append(row); + task.setOnChange(function() { + updateTaskRow(row, task); + }); + }); + + tasks.setOnRemove(function(index) { + findRowWithIndex(index).remove(); + if (tasks.length() == 1 ) { + $( "#task-table" ).css({"display":"none"}); + } + }); +} + +/* Initializes GUI event handlers. */ +function initializeGuiEventHandlers() { + $( "#addtimer" ).click(function() { + displayTaskAdd(); + }); + + $( "#timer-desc" ).keypress(function( event ) { + if ( event.which == 13 ) { + event.preventDefault(); + displayTaskAdd(); + } + }); + +} +function displayTaskAdd() { + $( "#task-table" ).css({"display":"inline"}); + var taskName = $("#timer-task option:selected").text(); + var taskId = $("#timer-task option:selected").val(); + var taskDesc = $("#timer-desc").val(); + + var task = new Task(taskId, taskName, taskDesc); + tasks.add(task); + saveTasks(); + $("#timer-desc").val(""); +} +/* + * Initializes a timer used to update task times and detect changes in the + * data stored in the DOM Storage. + */ +function initializeTimer() { + setInterval(function() { + tasks.forEach(function(task, index) { + updateTaskRow(findRowWithIndex(index), task); + }); + + if (tasksHaveChangedOutsideApplication()) { + loadTasks(); + } + }, 10 * MILISECONDS_IN_SECOND); +} + +/* Initializes the application. */ +$(document).ready(function(){ + try { + if (!getStorage()) { + alert("Timer requires a browser with DOM Storage support, such as Firefox 3+ or Safari 4+."); + return; + } + } catch (e) { + alert("Timer does not work with file: URLs in Firefox."); + return; + } + + initializeTasksEventHandlers(); + loadTasks(); + initializeGuiEventHandlers(); + initializeTimer(); +}); + + + + + +{% endblock %} + + diff --git a/src/schedule-2.0/templates/base.html.twig b/src/schedule-2.0/templates/base.html.twig index cbaf97b..baa390b 100644 --- a/src/schedule-2.0/templates/base.html.twig +++ b/src/schedule-2.0/templates/base.html.twig @@ -359,6 +359,12 @@ +
  • + + Suivi Horaire + +
  • +
  • Mes Congés -- 2.17.1 From 7b6e78f473351ec148f1d539b8c9bc02f07edecb Mon Sep 17 00:00:00 2001 From: Matthieu Lamalle Date: Wed, 1 Jul 2020 16:01:50 +0200 Subject: [PATCH 02/10] suppression de public/build dans gitignore --- src/schedule-2.0/.gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/src/schedule-2.0/.gitignore b/src/schedule-2.0/.gitignore index ba27aaf..4a6cd2b 100644 --- a/src/schedule-2.0/.gitignore +++ b/src/schedule-2.0/.gitignore @@ -17,7 +17,6 @@ ###> symfony/webpack-encore-bundle ### /node_modules/ -/public/build/ npm-debug.log yarn-error.log ###< symfony/webpack-encore-bundle ### -- 2.17.1 From aefc645c02a30caac477eb1298272f5ab76a466a Mon Sep 17 00:00:00 2001 From: Matthieu Lamalle Date: Thu, 2 Jul 2020 10:13:33 +0200 Subject: [PATCH 03/10] timer description no mandatory --- src/schedule-2.0/src/Form/TimerType.php | 26 ++++++++++++------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/schedule-2.0/src/Form/TimerType.php b/src/schedule-2.0/src/Form/TimerType.php index f53771f..fb6d51f 100644 --- a/src/schedule-2.0/src/Form/TimerType.php +++ b/src/schedule-2.0/src/Form/TimerType.php @@ -39,22 +39,23 @@ class TimerType extends AbstractType ] ); $builder->add('task', - EntityType::class, [ - "label" => "Tâche", - "class" => "App:Task", - "choice_label" => function ($task) { - return $task->getDisplayname();}, - - "disabled" => false, - "required" => true, - "multiple" => false, - "placeholder" => "Selectionner une Tâche", + EntityType::class, [ + "label" => "Tâche", + "class" => "App:Task", + "choice_label" => function ($task) { + return $task->getDisplayname();}, + + "disabled" => false, + "required" => true, + "multiple" => false, + "placeholder" => "Selectionner une Tâche", ] ); $builder->add('description', TextType::class, [ - "label" => "Description" + "label" => "Description", + "required" => false ] ); @@ -76,9 +77,6 @@ class TimerType extends AbstractType ] ); - $builder->addEventListener( - - ); $builder->add('duration', TimeType::class, [ "label" =>"Durée", -- 2.17.1 From 03456567069fbeefab9ceb3542e36f5d3f27b6f2 Mon Sep 17 00:00:00 2001 From: Matthieu Lamalle Date: Thu, 2 Jul 2020 10:24:18 +0200 Subject: [PATCH 04/10] correction affectation utilisateur, et ajout vue caendrier --- src/schedule-2.0/config/routes.yaml | 18 +- .../src/Controller/TimerController.php | 78 +++++++- .../templates/Timer/list.cal.html.twig | 186 ++++++++++++++++++ .../templates/Timer/list.html.twig | 27 +-- 4 files changed, 290 insertions(+), 19 deletions(-) create mode 100644 src/schedule-2.0/templates/Timer/list.cal.html.twig diff --git a/src/schedule-2.0/config/routes.yaml b/src/schedule-2.0/config/routes.yaml index d088699..9655c7c 100644 --- a/src/schedule-2.0/config/routes.yaml +++ b/src/schedule-2.0/config/routes.yaml @@ -349,23 +349,31 @@ app_validationholiday_activeholiday: #== Timer ==================================================================================================== app_timer: - path: /user/timer + path: /timer defaults: { _controller: App\Controller\TimerController:list } +app_timer_view: + path: /timer/event + defaults: { _controller: App\Controller\TimerController:view } + +app_timer_load: + path: /timer/event/load + defaults: { _controller: App\Controller\TimerController:load } + app_timer_submit: - path: /user/timer/submit + path: /timer/submit defaults: { _controller: App\Controller\TimerController:submit } app_timer_create: - path: /user/timer/create + path: /timer/create defaults: { _controller: App\Controller\TimerController:create } app_timer_update: - path: /user/timer/update/{id} + path: /timer/update/{id} defaults: { _controller: App\Controller\TimerController:update } app_timer_delete: - path: /user/timer/delete/{id} + path: /timer/delete/{id} defaults: { _controller: App\Controller\TimerController:delete } #== Customer ====================================================================================================== diff --git a/src/schedule-2.0/src/Controller/TimerController.php b/src/schedule-2.0/src/Controller/TimerController.php index effe14c..3ccc4b7 100644 --- a/src/schedule-2.0/src/Controller/TimerController.php +++ b/src/schedule-2.0/src/Controller/TimerController.php @@ -35,6 +35,48 @@ class TimerController extends AbstractController ]); } + public function view(Request $request) + { + $em = $this->getDoctrine()->getManager(); + + $iduser = $this->get("session")->get("iduser"); + $user = $em->getRepository("App:User")->find($iduser); + $tasks = $em->getRepository("App:Task")->findAll(); + $timers = $em->getRepository("App:Timer")->findBy(["user"=>$iduser]); + + return $this->render($this->render.'list.cal.html.twig',[ + "useheader" => true, + "usesidebar" => true, + "user" => $user, + "tasks" => $tasks, + "timers" => $timers, + ]); + } + + public function load(Request $request) + { + $em = $this->getDoctrine()->getManager(); + $tbtimers=[]; + + // Evenements + $iduser=$this->get("session")->get("iduser"); + if($iduser=="all") + $timers=$em->getRepository("App:Timer")->findAll(); + else { + $iduser=$this->get("session")->get("iduser"); + $user=$em->getRepository("App:User")->find($iduser); + $timers=$em->getRepository("App:Timer")->findBy(["user"=>$user]); + } + + foreach($timers as $timer) { + $tmp=$this->formatTimer($timer); + array_push($tbtimers,$tmp); + } + + // Retour + return new Response(json_encode($tbtimers)); + } + public function create(Request $request) { // Initialisation de l'enregistrement @@ -62,11 +104,15 @@ class TimerController extends AbstractController $output=["return"=>"OK"]; return new Response(json_encode($output)); } + public function submit(Request $request) { // Initialisation de l'enregistrement $em = $this->getDoctrine()->getManager(); $data = new Entity(); + + $iduser = $this->get("session")->get("iduser"); + $user = $em->getRepository("App:User")->find($iduser); // Création du formulaire $form = $this->createForm(Form::class,$data,array("mode"=>"submit")); @@ -79,7 +125,8 @@ class TimerController extends AbstractController // Sur validation if ($form->get('submit')->isClicked() && $form->isValid()) { - $data = $form->getData(); + $data = $form->getData(); + $data->setUser($user); $em->persist($data); $em->flush(); @@ -173,4 +220,33 @@ class TimerController extends AbstractController } } } + public function formatTimer($timer) { + + $tmp= [ + "id"=> $timer->getId(), + "title" => $timer->getTask()->getDisplayname(), + "start" => $timer->getStart()->format("Y-m-d H:i"), + "end" => $timer->getEnd()->format("Y-m-d H:i"), + "backgroundColor" => $timer->getTask()->getColor(), + "borderColor" => $timer->getTask()->getColor(), + "textColor" => "#ffffff", + "allDay" => false, + "editable" => true, + "durationEditable" => false, + "extendedProps" => [ + "fulldescription" => $timer->getTask()->getDisplayname()."\n\n".$timer->getDescription(), + "description" => $timer->getDescription(), + "userid" => $timer->getUser()->getId(), + "username" => $timer->getUser()->getUsername(), + "taskid" => $timer->getTask()->getId(), + "avatar" => "/".$this->getParameter("appAlias")."/uploads/avatar/".$timer->getUser()->getAvatar(), + "estimate" => $timer->getDuration()->format("H:i"), + "locked" => false, + "editable" => true, + "astreinte" => false + ] + ]; + return $tmp; + } + } diff --git a/src/schedule-2.0/templates/Timer/list.cal.html.twig b/src/schedule-2.0/templates/Timer/list.cal.html.twig new file mode 100644 index 0000000..ea7b27f --- /dev/null +++ b/src/schedule-2.0/templates/Timer/list.cal.html.twig @@ -0,0 +1,186 @@ +{% extends "base.html.twig" %} + +{% block head_style %} + {{ encore_entry_link_tags('app') }} + {{ encore_entry_link_tags('fullcalendar') }} +{% endblock head_style %} + +{% block localstyle %} + .fc-header-toolbar h2 { + text-transform: uppercase; + } + + .fc-day-grid-event { + padding:0px; + border-radius:0px; + border: none; + } + .fc-content { + height: 40px; + } + + .fc-title { + font-weight: bolder; + font-size: 12px; + } + + .eventAvatar { + width: 20px; + margin: 0px 5px 0px 0px; + float: left; + } + + .eventInfo{ + margin: -5px 5px 0px 0px; + clear: both; + } + + .eventUser{ + clear: both; + } + + .eventEstimate { + margin: -3px 10px; + } +{% endblock %} + +{% block body %} +
    + + + {{ encore_entry_script_tags('fullcalendar') }} +{% endblock %} + + + + +{% block localjavascript %} + +$(document).ready(function() { + $("#modalsubmit #user").select2({ + theme: 'bootstrap4', + language: "fr" + }); + + $("#modalsubmit #task").select2({ + placeholder: "Selectionnez un projet", + allowClear: true, + theme: 'bootstrap4', + language: "fr" + }); + + $("#modalupdate #user").select2({ + theme: 'bootstrap4', + language: "fr" + }); + + $("#modalupdate #task").select2({ + placeholder: "Selectionnez un projet", + theme: 'bootstrap4', + language: "fr" + }); +}); + +// Rendu d'un évenement +function eventRender(info) { + console.log(info.event.extendedProps); + // Récupération des divers élements du rendu event + var content=$(info.el).children('.fc-content'); + var title=$(content).children('.fc-title'); + + // Ajouter l'avatar + content.prepend(""); + content.append(""+info.event.extendedProps.username+""); + var eventInfo=$(content).children('.eventUser'); + + // Ajout container + content.append(""); + var eventInfo=$(content).children('.eventInfo'); + + // Ajouter le verrou si event non editable + if(info.event.extendedProps.locked) { + eventInfo.append(""); + } + + // Ajout estimation + eventInfo.append(""+info.event.extendedProps.estimate+""); + + // Description + content.attr("title",info.event.extendedProps.fulldescription); +} + +// Formulaire Création d'un évelement +var allDay; +function eventSelect(selectionInfo) { + var start=moment(selectionInfo.start); + var end=moment(selectionInfo.end); + var end=end.subtract(1, 'd'); + allDay=(start.format("DD/MM/YYYY") != end.format("DD/MM/YYYY")); + + // Controle + if(start.month()!=end.month()) { + alert("Une tâche ne peut être sur deux mois différents"); + return false; + } + + if(start.week()!=end.week()) { + alert("Une tâche ne peut être sur deux semaines différentes"); + return false; + } + + // Valeur par défaut + {% if (is_granted('ROLE_ADMIN') or is_granted('ROLE_VALIDATOR') or is_granted('ROLE_MASTER')) and app.session.get('iduser')!="all" %} + $('#usersubmit').val({{app.session.get('iduser')}}).trigger("change"); + {% else %} + $('#usersubmit').val({{app.user.id}}).trigger("change"); + {% endif %} + + // Si jour de fin un samedi ou un dimanche : on est forcement en astreinte + if(moment(end).day()==0||moment(end).day()==6) { + $("#modalsubmit #astreinte").prop("checked",true); + $("#modalsubmit #astreinte").attr("disabled",true); + allDay=true; + } + else { + $("#modalsubmit #astreinte").prop('checked', false); + $("#modalsubmit #astreinte").attr('disabled', false); + } + + $('#modalsubmit #amsubmit').prop("checked",true); + $('#modalsubmit #apsubmit').prop("checked",true); + $('#modalsubmit #amsubmit').attr("disabled",allDay); + $('#modalsubmit #apsubmit').attr("disabled",allDay); + + $('#modalsubmit #start').val(start.format("YYYY-MM-DD")); + $('#modalsubmit #end').val(end.format("YYYY-MM-DD")); + + $('#modalsubmit #description').val(""); + + $("#modalsubmit .alert").remove(); + + // Formulaire de création d'un évènement + $('#modalsubmit').modal(); +} + + + +// On change astreinte +$("#astreinte").change(function() { + console.log(allDay) + if(this.checked) { + $("#amsubmit").prop("disabled",true); + $("#apsubmit").prop("disabled",true); + + $('#modalsubmit #amsubmit').prop("checked",true); + $('#modalsubmit #apsubmit').prop("checked",true); + } + else { + $("#amsubmit").prop("disabled",allDay); + $("#apsubmit").prop("disabled",allDay); + + $('#modalsubmit #amsubmit').prop("checked",true); + $('#modalsubmit #apsubmit').prop("checked",true); + } +}); + +{% endblock %} \ No newline at end of file diff --git a/src/schedule-2.0/templates/Timer/list.html.twig b/src/schedule-2.0/templates/Timer/list.html.twig index 8c82319..5bc64de 100644 --- a/src/schedule-2.0/templates/Timer/list.html.twig +++ b/src/schedule-2.0/templates/Timer/list.html.twig @@ -15,19 +15,20 @@ SUIVI HORAIRE
    - +
    + Voir le calendrier + Lancer un Timer : + + + + + +
    -- 2.17.1 From 23a265ccaeba299bbfede70a9c9d994c08c62d39 Mon Sep 17 00:00:00 2001 From: Matthieu Lamalle Date: Fri, 26 Jun 2020 11:57:09 +0200 Subject: [PATCH 05/10] typo --- src/schedule-2.0/templates/Report/report.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schedule-2.0/templates/Report/report.html.twig b/src/schedule-2.0/templates/Report/report.html.twig index 082a666..4796074 100644 --- a/src/schedule-2.0/templates/Report/report.html.twig +++ b/src/schedule-2.0/templates/Report/report.html.twig @@ -114,7 +114,7 @@ {% if access!="customer" and app.user %} {% endif %} -- 2.17.1 From 2920126fb780d1186794cd5c1e2a02e76b35a729 Mon Sep 17 00:00:00 2001 From: Matthieu Lamalle Date: Mon, 27 Jul 2020 15:22:16 +0200 Subject: [PATCH 06/10] Rapport hebdo par utilisateur --- .../src/Controller/ReportController.php | 78 ++++++++++++++++++- .../templates/Report/export.csv.twig | 15 ++-- 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/src/schedule-2.0/src/Controller/ReportController.php b/src/schedule-2.0/src/Controller/ReportController.php index b386544..8319f64 100755 --- a/src/schedule-2.0/src/Controller/ReportController.php +++ b/src/schedule-2.0/src/Controller/ReportController.php @@ -608,8 +608,27 @@ class ReportController extends AbstractController ]; } $tbproject["weeks"][$event->getStart()->format("Y")][$event->getStart()->format("W")]["cumul"] = $tbproject["weeks"][$event->getStart()->format("Y")][$event->getStart()->format("W")]["cumul"]+$event->getDuration(); - } + + foreach($eventsbyweek as $event) { + if(!isset($tbproject["weeks_by_name"][$event->getStart()->format("Y")][$event->getStart()->format("W")]["users"])){ + $tbproject["weeks_by_name"][$event->getStart()->format("Y")][$event->getStart()->format("W")] = [ + "weeknumber" => $event->getStart()->format("W"), + "users" => [], + ]; + } + if(!isset($tbproject["weeks_by_name"][$event->getStart()->format("Y")][$event->getStart()->format("W")]["users"][$event->getUser()->getId()])){ + $tbuser= [ + "id"=>$event->getUser()->getId(), + "displayname"=>$event->getUser()->getDisplayname(), + "cumul"=>0 + ]; + $tbproject["weeks_by_name"][$event->getStart()->format("Y")][$event->getStart()->format("W")]["users"][$event->getUser()->getId()] = $tbuser; + } + + $tbproject["weeks_by_name"][$event->getStart()->format("Y")][$event->getStart()->format("W")]["users"][$event->getUser()->getId()]["cumul"] = $tbproject["weeks_by_name"][$event->getStart()->format("Y")][$event->getStart()->format("W")]["users"][$event->getUser()->getId()]["cumul"]+$event->getDuration(); + } + // Somme astreintes validé par semaine $start=new \Datetime('first day of this month'); $start->sub(new \DateInterval('P'.$nbmonth.'M')); @@ -646,6 +665,24 @@ class ReportController extends AbstractController } $tbproject["weeks"][$penalty->getStart()->format("Y")][$penalty->getStart()->format("W")]["cumul"] = $tbproject["weeks"][$penalty->getStart()->format("Y")][$penalty->getStart()->format("W")]["cumul"]+$penalty->getDuration(); } + foreach($penaltybyweek as $penaltybyweek) { + if(!isset($tbproject["weeks_by_name"][$penalty->getStart()->format("Y")][$penalty->getStart()->format("W")]["users"])){ + $tbproject["weeks_by_name"][$penalty->getStart()->format("Y")][$penalty->getStart()->format("W")] = [ + "weeknumber" => $penalty->getStart()->format("W"), + "users" => [], + ]; + } + if(!isset($tbproject["weeks_by_name"][$penalty->getStart()->format("Y")][$penalty->getStart()->format("W")]["users"][$penalty->getUser()->getId()])){ + $tbuser= [ + "id"=>$penalty->getUser()->getId(), + "displayname"=>$penalty->getUser()->getDisplayname(), + "cumul"=>0 + ]; + $tbproject["weeks_by_name"][$penalty->getStart()->format("Y")][$penalty->getStart()->format("W")]["users"][$penalty->getUser()->getId()] = $tbuser; + } + + $tbproject["weeks_by_name"][$penalty->getStart()->format("Y")][$penalty->getStart()->format("W")]["users"][$penalty->getUser()->getId()]["cumul"] = $tbproject["weeks_by_name"][$penalty->getStart()->format("Y")][$penalty->getStart()->format("W")]["users"][$penalty->getUser()->getId()]["cumul"]+$penalty->getDuration(); + } // Recap des Commandes $offers=$em->getRepository("App:Offer")->findBy(["project"=>$project->getId()]); @@ -1103,6 +1140,24 @@ class ReportController extends AbstractController $tbproject["weeks"][$event->getStart()->format("Y")][$event->getStart()->format("W")]["cumul"] = $tbproject["weeks"][$event->getStart()->format("Y")][$event->getStart()->format("W")]["cumul"]+$event->getDuration(); } + foreach($eventsbyweek as $event) { + if(!isset($tbproject["weeks_by_name"][$event->getStart()->format("Y")][$event->getStart()->format("W")]["users"])){ + $tbproject["weeks_by_name"][$event->getStart()->format("Y")][$event->getStart()->format("W")] = [ + "weeknumber" => $event->getStart()->format("W"), + "users" => [], + ]; + } + if(!isset($tbproject["weeks_by_name"][$event->getStart()->format("Y")][$event->getStart()->format("W")]["users"][$event->getUser()->getId()])){ + $tbuser= [ + "id"=>$event->getUser()->getId(), + "displayname"=>$event->getUser()->getDisplayname(), + "cumul"=>0 + ]; + $tbproject["weeks_by_name"][$event->getStart()->format("Y")][$event->getStart()->format("W")]["users"][$event->getUser()->getId()] = $tbuser; + } + + $tbproject["weeks_by_name"][$event->getStart()->format("Y")][$event->getStart()->format("W")]["users"][$event->getUser()->getId()]["cumul"] = $tbproject["weeks_by_name"][$event->getStart()->format("Y")][$event->getStart()->format("W")]["users"][$event->getUser()->getId()]["cumul"]+$event->getDuration(); + } // Somme astreintes validé par semaine $start=new \Datetime('first day of this month'); $start->sub(new \DateInterval('P'.$nbmonth.'M')); @@ -1139,13 +1194,32 @@ class ReportController extends AbstractController } $tbproject["weeks"][$penalty->getStart()->format("Y")][$penalty->getStart()->format("W")]["cumul"] = $tbproject["weeks"][$penalty->getStart()->format("Y")][$penalty->getStart()->format("W")]["cumul"]+$penalty->getDuration(); } + foreach($penaltybyweek as $penalty) { + if(!isset($tbproject["weeks_by_name"][$penalty->getStart()->format("Y")][$penalty->getStart()->format("W")]["users"])){ + $tbproject["weeks_by_name"][$penalty->getStart()->format("Y")][$penalty->getStart()->format("W")] = [ + "weeknumber" => $penalty->getStart()->format("W"), + "users" => [], + ]; + } + if(!isset($tbproject["weeks_by_name"][$penalty->getStart()->format("Y")][$penalty->getStart()->format("W")]["users"][$penalty->getUser()->getId()])){ + $tbuser= [ + "id"=>$penalty->getUser()->getId(), + "displayname"=>$penalty->getUser()->getDisplayname(), + "cumul"=>0 + ]; + $tbproject["weeks_by_name"][$penalty->getStart()->format("Y")][$penalty->getStart()->format("W")]["users"][$penalty->getUser()->getId()] = $tbuser; + } + + $tbproject["weeks_by_name"][$penalty->getStart()->format("Y")][$penalty->getStart()->format("W")]["users"][$penalty->getUser()->getId()]["cumul"] = $tbproject["weeks_by_name"][$penalty->getStart()->format("Y")][$penalty->getStart()->format("W")]["users"][$penalty->getUser()->getId()]["cumul"]+$penalty->getDuration(); + } + $tbprojects[$project->getId()]=$tbproject; } $csv = $this->renderView('Report/export.csv.twig', ["projects" => $tbprojects]); $response = new Response($csv); $response->headers->set('Content-Type', 'text/csv; charset=utf-8'); - $response->headers->set('Content-Disposition', 'attachment; filename="export.csv"'); + //$response->headers->set('Content-Disposition', 'attachment; filename="export.csv"'); return $response; } diff --git a/src/schedule-2.0/templates/Report/export.csv.twig b/src/schedule-2.0/templates/Report/export.csv.twig index 97ec932..c1a5a81 100644 --- a/src/schedule-2.0/templates/Report/export.csv.twig +++ b/src/schedule-2.0/templates/Report/export.csv.twig @@ -1,15 +1,12 @@ {% block body %} +Projet;Utilisateur;Année;Semaine;Cumul; {% for project in projects %} -{{ project.projectname|raw }} -{% for years in project %} -{% for year,weeks in years %} -{{year}} -;{% for week in weeks %}S{{week.weeknumber}};{% endfor %} - -;{% for week in weeks %}{{week.cumul}};{% endfor %} - +{% for year,weeks in project.weeks_by_name %} +{% for week in weeks %} +{% for user in week.users%} +{{project.projectname}};{{user.displayname}};{{year}};S{{week.weeknumber}};{{user.cumul}}; +{% endfor %} {% endfor %} {% endfor %} - {% endfor %} {% endblock %} \ No newline at end of file -- 2.17.1 From 42ed87b0952d4ea0167644ea459440b1b2beca2e Mon Sep 17 00:00:00 2001 From: Matthieu Lamalle Date: Tue, 28 Jul 2020 09:41:34 +0200 Subject: [PATCH 07/10] set correct report format --- src/schedule-2.0/src/Controller/ReportController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schedule-2.0/src/Controller/ReportController.php b/src/schedule-2.0/src/Controller/ReportController.php index 8319f64..76bf981 100755 --- a/src/schedule-2.0/src/Controller/ReportController.php +++ b/src/schedule-2.0/src/Controller/ReportController.php @@ -1219,7 +1219,7 @@ class ReportController extends AbstractController $csv = $this->renderView('Report/export.csv.twig', ["projects" => $tbprojects]); $response = new Response($csv); $response->headers->set('Content-Type', 'text/csv; charset=utf-8'); - //$response->headers->set('Content-Disposition', 'attachment; filename="export.csv"'); + $response->headers->set('Content-Disposition', 'attachment; filename="export.csv"'); return $response; } -- 2.17.1 From 82a8118fd6d28ec633fe9fcdedf267523d43626f Mon Sep 17 00:00:00 2001 From: Matthieu Lamalle Date: Tue, 28 Jul 2020 13:50:24 +0200 Subject: [PATCH 08/10] =?UTF-8?q?ajout=20possibilit=C3=A9=20de=20modifier?= =?UTF-8?q?=20la=20plage=20horaire=20d'un=20=C3=A9v=C3=A9nement=20sur=20le?= =?UTF-8?q?=20calendrier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Controller/EventController.php | 68 ++++++++++++++++++- src/schedule-2.0/src/Entity/Event.php | 8 +-- .../templates/Event/list.html.twig | 42 ++++++++++-- 3 files changed, 107 insertions(+), 11 deletions(-) diff --git a/src/schedule-2.0/src/Controller/EventController.php b/src/schedule-2.0/src/Controller/EventController.php index 411ba35..79d153f 100755 --- a/src/schedule-2.0/src/Controller/EventController.php +++ b/src/schedule-2.0/src/Controller/EventController.php @@ -122,7 +122,7 @@ class EventController extends AbstractController if($am&&$ap) { $datestart->SetTime(0,0,0); $dateend->add(new \DateInterval('P1D')); - $dateend->SetTime(0,0,0); + $dateend->SetTime(0,0,-1); $duration=$dateend->diff($datestart)->d; $allday=true; } @@ -271,6 +271,8 @@ class EventController extends AbstractController $idevent = str_replace("A","",$request->request->get('idevent')); $iduser = $request->request->get('iduser'); $idtask = $request->request->get('idtask'); + $am = ($request->request->get('am')=="true"); + $ap = ($request->request->get('ap')=="true"); $fgastreinte = ($request->request->get('fgastreinte')=="true"); $description = $request->request->get('description'); @@ -327,17 +329,77 @@ class EventController extends AbstractController return new Response(json_encode($output)); } + $datestart=$event->getStart(); + $dateend =$event->getEnd(); + if($am&&$ap) { + $datestart->SetTime(0,0,0); + $dateend->add(new \DateInterval('P1D')); + $dateend->SetTime(0,0,-1); + $duration=$dateend->diff($datestart)->d; + $allday=true; + } + else { + $duration=0.5; + $allday=false; + if($am) { + $datestart->SetTime(9,0,0); + $dateend->SetTime(12,0,0); + } + else { + $datestart->SetTime(13,0,0); + $dateend->SetTime(17,0,0); + } + } + // On regarde si une tache ne commence pas pendant une autre intervention ou qui se termine pendant une autre intervention ou qui a une intervention compris dans ses dates + $events = $em->createQueryBuilder('event') + ->select('event') + ->from('App:Event','event') + ->Where('event.user=:user AND event.start<=:start AND event.end >:start') + ->orWhere('event.user=:user AND event.start<:end AND event.end >=:end') + ->orWhere('event.user=:user AND event.start>:start AND event.end <:end') + ->setParameter('user',$iduser) + ->setParameter('start',$datestart) + ->setParameter('end',$dateend) + ->getQuery()->getResult(); + dump($event->getId()); + if($events) { + $tbevent=[]; + foreach($events as $ev) { + + + if ($event->getId() != $ev->getId()) { + $tmp=[ + "id" => $ev->getId(), + "task" => $ev->getTask()->getName(), + "start" => $ev->getStart(), + "end" => $ev->getEnd(), + ]; + array_push($tbevent,$tmp); + } + } + if (sizeof($tbevent)>0) { + $output=["return"=>"KO","error"=>"Cet intervant a déjà une tache à cette date","start"=>$datestart,"end"=>$dateend,"events"=>$tbevent]; + return new Response(json_encode($output)); + } + + } + + // Modification de l'évenement + $event->setStart($datestart); + $event->setEnd($dateend); $event->setDescription($description); + $event->setDuration($duration); + $event->setAllday($allday); $event->setUser($user); $event->setTask($task); - + $em->persist($event); $em->flush(); $output=$this->formatEvent($event); + dump($event); } - return new Response(json_encode($output)); } diff --git a/src/schedule-2.0/src/Entity/Event.php b/src/schedule-2.0/src/Entity/Event.php index eb026c2..97d7942 100644 --- a/src/schedule-2.0/src/Entity/Event.php +++ b/src/schedule-2.0/src/Entity/Event.php @@ -96,24 +96,24 @@ class Event public function getStart(): ?\DateTimeInterface { - return $this->start; + return clone $this->start; } public function setStart(\DateTimeInterface $start): self { - $this->start = $start; + $this->start = clone $start; return $this; } public function getEnd(): ?\DateTimeInterface { - return $this->end; + return clone $this->end; } public function setEnd(\DateTimeInterface $end): self { - $this->end = $end; + $this->end = clone $end; return $this; } diff --git a/src/schedule-2.0/templates/Event/list.html.twig b/src/schedule-2.0/templates/Event/list.html.twig index 8a87477..7d1de63 100644 --- a/src/schedule-2.0/templates/Event/list.html.twig +++ b/src/schedule-2.0/templates/Event/list.html.twig @@ -157,7 +157,6 @@ {% endif %}
    -
    +
    +
    + + +
    +
    +
    +
    + + +
    +