Merge pull request #13363 from shreyashah115/timer
Timer in Timesheets!
This commit is contained in:
commit
e45f957ad9
Binary file not shown.
After Width: | Height: | Size: 109 KiB |
Binary file not shown.
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
After Width: | Height: | Size: 100 KiB |
BIN
erpnext/docs/assets/img/project/timesheet/timesheet-timer.gif
Normal file
BIN
erpnext/docs/assets/img/project/timesheet/timesheet-timer.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 MiB |
@ -1,3 +1,4 @@
|
||||
salary-slip-from-timesheet
|
||||
sales-invoice-from-timesheet
|
||||
timesheet-against-production-order
|
||||
timesheet-against-production-order
|
||||
timer-in-timesheet
|
@ -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">
|
23
erpnext/projects/doctype/timesheet/timesheet.css
Normal file
23
erpnext/projects/doctype/timesheet/timesheet.css
Normal file
@ -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;
|
||||
}
|
@ -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)
|
||||
},
|
||||
|
@ -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",
|
||||
|
155
erpnext/public/js/projects/timer.js
Normal file
155
erpnext/public/js/projects/timer.js
Normal file
@ -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();
|
||||
}
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user