Merge pull request #13363 from shreyashah115/timer
Timer in Timesheets!
diff --git a/erpnext/docs/assets/img/project/timesheet/timesheet-after-complete.png b/erpnext/docs/assets/img/project/timesheet/timesheet-after-complete.png
new file mode 100644
index 0000000..6138176
--- /dev/null
+++ b/erpnext/docs/assets/img/project/timesheet/timesheet-after-complete.png
Binary files differ
diff --git a/erpnext/docs/assets/img/project/timesheet/timesheet-timer-alert.png b/erpnext/docs/assets/img/project/timesheet/timesheet-timer-alert.png
new file mode 100644
index 0000000..46da2ee
--- /dev/null
+++ b/erpnext/docs/assets/img/project/timesheet/timesheet-timer-alert.png
Binary files differ
diff --git a/erpnext/docs/assets/img/project/timesheet/timesheet-timer-in-progress.png b/erpnext/docs/assets/img/project/timesheet/timesheet-timer-in-progress.png
new file mode 100644
index 0000000..a060344
--- /dev/null
+++ b/erpnext/docs/assets/img/project/timesheet/timesheet-timer-in-progress.png
Binary files differ
diff --git a/erpnext/docs/assets/img/project/timesheet/timesheet-timer.gif b/erpnext/docs/assets/img/project/timesheet/timesheet-timer.gif
new file mode 100644
index 0000000..a838614
--- /dev/null
+++ b/erpnext/docs/assets/img/project/timesheet/timesheet-timer.gif
Binary files differ
diff --git a/erpnext/docs/user/manual/en/projects/timesheet/index.txt b/erpnext/docs/user/manual/en/projects/timesheet/index.txt
index 47d5ea1..9cd8cee 100644
--- a/erpnext/docs/user/manual/en/projects/timesheet/index.txt
+++ b/erpnext/docs/user/manual/en/projects/timesheet/index.txt
@@ -1,3 +1,4 @@
salary-slip-from-timesheet
sales-invoice-from-timesheet
-timesheet-against-production-order
\ No newline at end of file
+timesheet-against-production-order
+timer-in-timesheet
\ No newline at end of file
diff --git a/erpnext/docs/user/manual/en/projects/timesheet/timer-in-timesheet.md b/erpnext/docs/user/manual/en/projects/timesheet/timer-in-timesheet.md
new file mode 100644
index 0000000..9e3bac4
--- /dev/null
+++ b/erpnext/docs/user/manual/en/projects/timesheet/timer-in-timesheet.md
@@ -0,0 +1,26 @@
+
+# Timer in Timesheet
+
+Timesheets can be tracked against Project and Tasks along with a Timer.
+
+<img class="screenshot" alt="Timer" src="{{docs_base_url}}/assets/img/project/timesheet/timesheet-timer.gif">
+
+#### Steps to start a Timer:
+
+- On clicking, **Start Timer**, a dialog pops up and starts the timer for already present activity for which checkbox `completed` is unchecked.
+
+<img class="screenshot" alt="Timer in Progress" src="{{docs_base_url}}/assets/img/project/timesheet/timesheet-timer-in-progress.png">
+
+- If no activities are present, fill up the activity details, i.e. activity type, expected hours or project in the dialog itself, on clicking **Start**, a new row is added into the Timesheet Details child table and timer begins.
+
+- On clicking, **Complete**, the `hours` and `to_time` fields are updated for that particular activity.
+
+<img class="screenshot" alt="Timer Completed" src="{{docs_base_url}}/assets/img/project/timesheet/timesheet-after-complete.png">
+
+- At any point of time, if the dialog is closed without completing the activity, on opening the dialog again, the timer resumes by calculating how much time has elapsed since `from_time` of the activity.
+
+- If any activities are already present in the Timesheet with completed unchecked, clicking on **Resume Timer** fetches the activity and starts its timer.
+
+- If the time exceeds the `expected_hours`, an alert box appears.
+
+<img class="screenshot" alt="Timer Exceeded" src="{{docs_base_url}}/assets/img/project/timesheet/timesheet-timer-alert.png">
diff --git a/erpnext/projects/doctype/timesheet/timesheet.css b/erpnext/projects/doctype/timesheet/timesheet.css
new file mode 100644
index 0000000..3a38415
--- /dev/null
+++ b/erpnext/projects/doctype/timesheet/timesheet.css
@@ -0,0 +1,23 @@
+.stopwatch {
+ text-align: center;
+ padding: 1em;
+ padding-bottom: 1em;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.stopwatch span {
+ display: inline-block;
+ position: relative;
+ font-size: 3em;
+ font-family: menlo;
+}
+
+.stopwatch .colon {
+ margin-top: -8px;
+}
+.playpause {
+ border-right: 1px dashed #fff;
+ border-bottom: 1px dashed #fff;
+}
\ No newline at end of file
diff --git a/erpnext/projects/doctype/timesheet/timesheet.js b/erpnext/projects/doctype/timesheet/timesheet.js
index 99ee2a2..678c016 100644
--- a/erpnext/projects/doctype/timesheet/timesheet.js
+++ b/erpnext/projects/doctype/timesheet/timesheet.js
@@ -3,6 +3,7 @@
frappe.ui.form.on("Timesheet", {
setup: function(frm) {
+ frappe.require("/assets/erpnext/js/projects/timer.js");
frm.add_fetch('employee', 'employee_name', 'employee_name');
frm.fields_dict.employee.get_query = function() {
return {
@@ -50,6 +51,41 @@
}
}
+ if (frm.doc.docstatus < 1) {
+
+ $.each(frm.doc.time_logs || [], function(i, row) {
+ if(row.from_time && !row.completed) {
+ if (row.to_time && frappe.datetime.now_datetime() > row.to_time) {
+ frappe.utils.play_sound("alert");
+ frappe.msgprint(__(`Timer exceeded the expected hours for activity ${row.activity_type} in row ${row.idx}.`));
+ }
+ }
+ frm.refresh_fields();
+ });
+
+ let button = 'Start Timer';
+ $.each(frm.doc.time_logs || [], function(i, row) {
+ if ((row.from_time <= frappe.datetime.now_datetime()) && !row.completed) {
+ button = 'Resume Timer';
+ }
+ })
+
+ frm.add_custom_button(__(button), function() {
+ var flag = true;
+ // Fetch the row for timer where activity is not completed and from_time is not <= now_time
+ $.each(frm.doc.time_logs || [], function(i, row) {
+ if (flag && row.from_time <= frappe.datetime.now_datetime() && !row.completed) {
+ let timestamp = moment(frappe.datetime.now_datetime()).diff(moment(row.from_time),"seconds");
+ erpnext.timesheet.timer(frm, row, timestamp);
+ flag = false;
+ }
+ })
+ // If no activities found to start a timer, create new
+ if (flag) {
+ erpnext.timesheet.timer(frm);
+ }
+ }).addClass("btn-primary");
+ }
if(frm.doc.per_billed > 0) {
frm.fields_dict["time_logs"].grid.toggle_enable("billing_hours", false);
frm.fields_dict["time_logs"].grid.toggle_enable("billable", false);
@@ -86,7 +122,6 @@
}
})
})
-
dialog.show();
},
@@ -114,7 +149,15 @@
frappe.model.set_value(cdt, cdn, "hours", moment(child.to_time).diff(moment(child.from_time),
"seconds") / 3600);
},
-
+ time_logs_add: function(frm) {
+ var $trigger_again = $('.form-grid').find('.grid-row').find('.btn-open-row');
+ $trigger_again.on('click', () => {
+ $('.form-grid')
+ .find('[data-fieldname="timer"]')
+ .append(frappe.render_template("timesheet"));
+ frm.trigger("control_timer");
+ })
+ },
hours: function(frm, cdt, cdn) {
calculate_end_time(frm, cdt, cdn)
},
diff --git a/erpnext/projects/doctype/timesheet_detail/timesheet_detail.json b/erpnext/projects/doctype/timesheet_detail/timesheet_detail.json
index d82e79d..b948238 100644
--- a/erpnext/projects/doctype/timesheet_detail/timesheet_detail.json
+++ b/erpnext/projects/doctype/timesheet_detail/timesheet_detail.json
@@ -39,6 +39,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -69,6 +70,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -98,6 +100,38 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "expected_hours",
+ "fieldtype": "Float",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Expected Hrs",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -127,6 +161,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -157,6 +192,40 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "default": "0",
+ "fieldname": "completed",
+ "fieldtype": "Check",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Completed",
+ "length": 0,
+ "no_copy": 0,
+ "options": "",
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -186,6 +255,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -194,7 +264,7 @@
"bold": 0,
"collapsible": 0,
"columns": 0,
- "depends_on": "eval:parent.work_order",
+ "depends_on": "eval:parent.production_order",
"fieldname": "completed_qty",
"fieldtype": "Float",
"hidden": 0,
@@ -217,6 +287,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -225,7 +296,7 @@
"bold": 0,
"collapsible": 0,
"columns": 0,
- "depends_on": "eval:parent.work_order",
+ "depends_on": "eval:parent.production_order",
"fieldname": "workstation",
"fieldtype": "Link",
"hidden": 0,
@@ -249,6 +320,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -278,6 +350,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -286,7 +359,7 @@
"bold": 0,
"collapsible": 0,
"columns": 0,
- "depends_on": "eval:parent.work_order",
+ "depends_on": "eval:parent.production_order",
"fieldname": "operation",
"fieldtype": "Link",
"hidden": 0,
@@ -310,6 +383,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -318,7 +392,7 @@
"bold": 0,
"collapsible": 0,
"columns": 0,
- "depends_on": "eval:parent.work_order",
+ "depends_on": "eval:parent.production_order",
"fieldname": "operation_id",
"fieldtype": "Data",
"hidden": 1,
@@ -341,6 +415,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -370,6 +445,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -401,6 +477,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -430,6 +507,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -462,6 +540,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -491,6 +570,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -522,6 +602,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -551,6 +632,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -582,6 +664,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -612,6 +695,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -643,6 +727,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -676,6 +761,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -705,6 +791,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -735,6 +822,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -767,6 +855,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -797,6 +886,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -828,6 +918,7 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
+ "translatable": 0,
"unique": 0
}
],
@@ -841,7 +932,7 @@
"issingle": 0,
"istable": 1,
"max_attachments": 0,
- "modified": "2018-01-07 11:46:04.045313",
+ "modified": "2018-03-21 17:13:32.561550",
"modified_by": "Administrator",
"module": "Projects",
"name": "Timesheet Detail",
diff --git a/erpnext/public/js/projects/timer.js b/erpnext/public/js/projects/timer.js
new file mode 100644
index 0000000..925398d
--- /dev/null
+++ b/erpnext/public/js/projects/timer.js
@@ -0,0 +1,155 @@
+frappe.provide("erpnext.timesheet");
+
+erpnext.timesheet.timer = function(frm, row, timestamp=0) {
+ let dialog = new frappe.ui.Dialog({
+ title: __("Timer"),
+ fields:
+ [
+ {"fieldtype": "Link", "label": __("Activity Type"), "fieldname": "activity_type",
+ "reqd": 1, "options": "Activity Type"},
+ {"fieldtype": "Link", "label": __("Project"), "fieldname": "project", "options": "Project"},
+ {"fieldtype": "Link", "label": __("Task"), "fieldname": "task", "options": "Task"},
+ {"fieldtype": "Float", "label": __("Expected Hrs"), "fieldname": "expected_hours"},
+ {"fieldtype": "Section Break"},
+ {"fieldtype": "HTML", "fieldname": "timer_html"}
+ ]
+ });
+
+ if (row) {
+ dialog.set_values({
+ 'activity_type': row.activity_type,
+ 'project': row.project,
+ 'task': row.task,
+ 'expected_hours': row.expected_hours
+ });
+ }
+ dialog.get_field("timer_html").$wrapper.append(get_timer_html());
+ function get_timer_html() {
+ return `
+ <div class="stopwatch">
+ <span class="hours">00</span>
+ <span class="colon">:</span>
+ <span class="minutes">00</span>
+ <span class="colon">:</span>
+ <span class="seconds">00</span>
+ </div>
+ <div class="playpause text-center">
+ <button class= "btn btn-primary btn-start"> ${ __("Start") } </button>
+ <button class= "btn btn-primary btn-complete"> ${ __("Complete") } </button>
+ </div>
+ `;
+ }
+ erpnext.timesheet.control_timer(frm, dialog, row, timestamp);
+ dialog.show();
+};
+
+erpnext.timesheet.control_timer = function(frm, dialog, row, timestamp=0) {
+ var $btn_start = $(".playpause .btn-start");
+ var $btn_complete = $(".playpause .btn-complete");
+ var interval = null;
+ var currentIncrement = timestamp;
+ var initialised = row ? true : false;
+ var clicked = false;
+
+ // If row with not completed status, initialize timer with the time elapsed on click of 'Start Timer'.
+ if (row) {
+ initialised = true;
+ $btn_start.hide();
+ $btn_complete.show();
+ initialiseTimer();
+ }
+ if (!initialised) {
+ $btn_complete.hide();
+ }
+ $btn_start.click(function(e) {
+ if (!initialised) {
+ // New activity if no activities found
+ var args = dialog.get_values();
+ if(!args) return;
+ if (!frm.doc.time_logs[0].activity_type) {
+ frm.doc.time_logs = [];
+ }
+ row = frappe.model.add_child(frm.doc, "Timesheet Detail", "time_logs");
+ row.activity_type = args.activity_type;
+ row.from_time = frappe.datetime.get_datetime_as_string();
+ row.project = args.project;
+ row.task = args.task;
+ row.expected_hours = args.expected_hours;
+ row.completed = 0;
+ let d = moment(row.from_time);
+ if(row.expected_hours) {
+ d.add(row.expected_hours, "hours");
+ row.to_time = d.format(moment.defaultDatetimeFormat);
+ }
+ frm.refresh_field("time_logs");
+ frm.save();
+ }
+
+ if (clicked) {
+ e.preventDefault();
+ return false;
+ }
+
+ if (!initialised) {
+ initialised = true;
+ $btn_start.hide();
+ $btn_complete.show();
+ initialiseTimer();
+ }
+ });
+
+ // Stop the timer and update the time logged by the timer on click of 'Complete' button
+ $btn_complete.click(function() {
+ var grid_row = cur_frm.fields_dict['time_logs'].grid.get_row(row.idx - 1);
+ var args = dialog.get_values();
+ grid_row.doc.completed = 1;
+ grid_row.doc.activity_type = args.activity_type;
+ grid_row.doc.project = args.project;
+ grid_row.doc.task = args.task;
+ grid_row.doc.expected_hours = args.expected_hours;
+ grid_row.doc.hours = currentIncrement / 3600;
+ grid_row.doc.to_time = frappe.datetime.now_datetime();
+ grid_row.refresh();
+ frm.save();
+ reset();
+ dialog.hide();
+ });
+ function initialiseTimer() {
+ interval = setInterval(function() {
+ var current = setCurrentIncrement();
+ updateStopwatch(current);
+ }, 1000);
+ }
+
+ function updateStopwatch(increment) {
+ var hours = Math.floor(increment / 3600);
+ var minutes = Math.floor((increment - (hours * 3600)) / 60);
+ var seconds = increment - (hours * 3600) - (minutes * 60);
+
+ // If modal is closed by clicking anywhere outside, reset the timer
+ if (!$('.modal-dialog').is(':visible')) {
+ reset();
+ }
+ if(hours > 99)
+ reset();
+ $(".hours").text(hours < 10 ? ("0" + hours.toString()) : hours.toString());
+ $(".minutes").text(minutes < 10 ? ("0" + minutes.toString()) : minutes.toString());
+ $(".seconds").text(seconds < 10 ? ("0" + seconds.toString()) : seconds.toString());
+ }
+
+ function setCurrentIncrement() {
+ currentIncrement += 1;
+ return currentIncrement;
+ }
+
+ function reset() {
+ currentIncrement = 0;
+ initialised = false;
+ clearInterval(interval);
+ $(".hours").text("00");
+ $(".minutes").text("00");
+ $(".seconds").text("00");
+ $btn_complete.hide();
+ $btn_start.show();
+ }
+};
\ No newline at end of file