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 0000000000..6138176ecf Binary files /dev/null and b/erpnext/docs/assets/img/project/timesheet/timesheet-after-complete.png 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 0000000000..46da2eee17 Binary files /dev/null and b/erpnext/docs/assets/img/project/timesheet/timesheet-timer-alert.png 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 0000000000..a060344f4d Binary files /dev/null and b/erpnext/docs/assets/img/project/timesheet/timesheet-timer-in-progress.png 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 0000000000..a838614d39 Binary files /dev/null and b/erpnext/docs/assets/img/project/timesheet/timesheet-timer.gif differ diff --git a/erpnext/docs/user/manual/en/projects/timesheet/index.txt b/erpnext/docs/user/manual/en/projects/timesheet/index.txt index 47d5ea1e39..9cd8cee924 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 0000000000..9e3bac47b2 --- /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. + +Timer + +#### 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. + +Timer in Progress + +- 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. + +Timer Completed + +- 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. + +Timer Exceeded diff --git a/erpnext/projects/doctype/timesheet/timesheet.css b/erpnext/projects/doctype/timesheet/timesheet.css new file mode 100644 index 0000000000..3a38415e6c --- /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 99ee2a2c5e..678c01639f 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 @@ frappe.ui.form.on("Timesheet", { } } + 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 @@ frappe.ui.form.on("Timesheet", { } }) }) - dialog.show(); }, @@ -114,7 +149,15 @@ frappe.ui.form.on("Timesheet Detail", { 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 d82e79d3ed..b9482384a8 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 0000000000..925398db6a --- /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 ` +
+ 00 + : + 00 + : + 00 +
+
+ + +
+ `; + } + 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