summaryrefslogtreecommitdiff
path: root/addons/hr_attendance/static
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/hr_attendance/static
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/hr_attendance/static')
-rw-r--r--addons/hr_attendance/static/description/icon.pngbin0 -> 8577 bytes
-rw-r--r--addons/hr_attendance/static/description/icon.svg1
-rw-r--r--addons/hr_attendance/static/src/js/employee_kanban_view_handler.js35
-rw-r--r--addons/hr_attendance/static/src/js/greeting_message.js182
-rw-r--r--addons/hr_attendance/static/src/js/kiosk_confirm.js94
-rw-r--r--addons/hr_attendance/static/src/js/kiosk_mode.js85
-rw-r--r--addons/hr_attendance/static/src/js/my_attendances.js56
-rw-r--r--addons/hr_attendance/static/src/js/time_widget.js19
-rw-r--r--addons/hr_attendance/static/src/scss/hr_attendance.scss228
-rw-r--r--addons/hr_attendance/static/src/xml/attendance.xml145
-rw-r--r--addons/hr_attendance/static/tests/hr_attendance_tests.js189
11 files changed, 1034 insertions, 0 deletions
diff --git a/addons/hr_attendance/static/description/icon.png b/addons/hr_attendance/static/description/icon.png
new file mode 100644
index 00000000..449fe487
--- /dev/null
+++ b/addons/hr_attendance/static/description/icon.png
Binary files differ
diff --git a/addons/hr_attendance/static/description/icon.svg b/addons/hr_attendance/static/description/icon.svg
new file mode 100644
index 00000000..bef191e7
--- /dev/null
+++ b/addons/hr_attendance/static/description/icon.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70"><defs><path id="a" d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z"/><linearGradient id="c" x1="100%" x2="0%" y1="0%" y2="100%"><stop offset="0%" stop-color="#94B6C8"/><stop offset="100%" stop-color="#6A9EBA"/></linearGradient><path id="d" d="M28.278 17.235c5.193 0 9.404 4.271 9.404 9.54 0 5.27-4.21 9.541-9.404 9.541s-9.404-4.271-9.404-9.54c0-5.27 4.21-9.541 9.404-9.541zm6.018 19.492c0-.413-9.178 1.847-12.6-.65l-4.19 1.063c-2.512.638-4.275 2.927-4.275 5.554v2.209c0 1.58 1.264 2.862 2.822 2.862h15.466c-1.852-6.34 2.777-10.625 2.777-11.038zM45.5 33.794c-6.466 0-11.706 5.24-11.706 11.706 0 6.466 5.24 11.706 11.706 11.706 6.466 0 11.706-5.24 11.706-11.706 0-6.466-5.24-11.706-11.706-11.706zm2.695 16.525l-4.163-3.025a.57.57 0 0 1-.231-.458v-7.944c0-.312.255-.566.566-.566h2.266c.311 0 .566.254.566.566v6.5l2.997 2.18a.566.566 0 0 1 .123.793l-1.33 1.831a.57.57 0 0 1-.794.123z"/><path id="e" d="M28.278 15.235c5.193 0 9.404 4.271 9.404 9.54 0 5.27-4.21 9.541-9.404 9.541s-9.404-4.271-9.404-9.54c0-5.27 4.21-9.541 9.404-9.541zm6.018 19.492c0-.413-9.178 1.847-12.6-.65l-4.19 1.063c-2.512.638-4.275 2.927-4.275 5.554v2.209c0 1.58 1.264 2.862 2.822 2.862h15.466c-1.852-6.34 2.777-10.625 2.777-11.038zM45.5 31.794c-6.466 0-11.706 5.24-11.706 11.706 0 6.466 5.24 11.706 11.706 11.706 6.466 0 11.706-5.24 11.706-11.706 0-6.466-5.24-11.706-11.706-11.706zm2.695 16.525l-4.163-3.025a.57.57 0 0 1-.231-.458v-7.944c0-.312.255-.566.566-.566h2.266c.311 0 .566.254.566.566v6.5l2.997 2.18a.566.566 0 0 1 .123.793l-1.33 1.831a.57.57 0 0 1-.794.123z"/></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><path fill="url(#c)" d="M0 0H70V70H0z"/><path fill="#FFF" fill-opacity=".383" d="M4 1h61c2.667 0 4.333.667 5 2V0H0v3c.667-1.333 2-2 4-2z"/><path fill="#393939" d="M42.488 69H4c-2 0-4-1-4-4V41.348l20.938-22.35 14.703 11.46-4.201 5.183-1.252 7.42 6.406-7.111L46 33l9.958 15.844L42.488 69z" opacity=".324"/><path fill="#000" fill-opacity=".383" d="M4 69h61c2.667 0 4.333-1 5-3v4H0v-4c.667 2 2 3 4 3z"/><use fill="#000" fill-rule="nonzero" opacity=".3" xlink:href="#d"/><use fill="#FFF" fill-rule="nonzero" xlink:href="#e"/></g></g></svg> \ No newline at end of file
diff --git a/addons/hr_attendance/static/src/js/employee_kanban_view_handler.js b/addons/hr_attendance/static/src/js/employee_kanban_view_handler.js
new file mode 100644
index 00000000..02801f79
--- /dev/null
+++ b/addons/hr_attendance/static/src/js/employee_kanban_view_handler.js
@@ -0,0 +1,35 @@
+
+odoo.define('hr_attendance.employee_kanban_view_handler', function(require) {
+"use strict";
+
+var KanbanRecord = require('web.KanbanRecord');
+
+KanbanRecord.include({
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _openRecord: function () {
+ if (this.modelName === 'hr.employee.public' && this.$el.parents('.o_hr_employee_attendance_kanban').length) {
+ // needed to diffentiate : check in/out kanban view of employees <-> standard employee kanban view
+ var action = {
+ type: 'ir.actions.client',
+ name: 'Confirm',
+ tag: 'hr_attendance_kiosk_confirm',
+ employee_id: this.record.id.raw_value,
+ employee_name: this.record.name.raw_value,
+ employee_state: this.record.attendance_state.raw_value,
+ employee_hours_today: this.record.hours_today.raw_value,
+ };
+ this.do_action(action);
+ } else {
+ this._super.apply(this, arguments);
+ }
+ }
+});
+
+});
diff --git a/addons/hr_attendance/static/src/js/greeting_message.js b/addons/hr_attendance/static/src/js/greeting_message.js
new file mode 100644
index 00000000..101f6dae
--- /dev/null
+++ b/addons/hr_attendance/static/src/js/greeting_message.js
@@ -0,0 +1,182 @@
+odoo.define('hr_attendance.greeting_message', function (require) {
+"use strict";
+
+var AbstractAction = require('web.AbstractAction');
+var core = require('web.core');
+var time = require('web.time');
+
+var _t = core._t;
+
+
+var GreetingMessage = AbstractAction.extend({
+ contentTemplate: 'HrAttendanceGreetingMessage',
+
+ events: {
+ "click .o_hr_attendance_button_dismiss": function() { this.do_action(this.next_action, {clear_breadcrumbs: true}); },
+ },
+
+ init: function(parent, action) {
+ var self = this;
+ this._super.apply(this, arguments);
+ this.activeBarcode = true;
+
+ // if no correct action given (due to an erroneous back or refresh from the browser), we set the dismiss button to return
+ // to the (likely) appropriate menu, according to the user access rights
+ if(!action.attendance) {
+ this.activeBarcode = false;
+ this.getSession().user_has_group('hr_attendance.group_hr_attendance_user').then(function(has_group) {
+ if(has_group) {
+ self.next_action = 'hr_attendance.hr_attendance_action_kiosk_mode';
+ } else {
+ self.next_action = 'hr_attendance.hr_attendance_action_my_attendances';
+ }
+ });
+ return;
+ }
+
+ this.next_action = action.next_action || 'hr_attendance.hr_attendance_action_my_attendances';
+ // no listening to barcode scans if we aren't coming from the kiosk mode (and thus not going back to it with next_action)
+ if (this.next_action != 'hr_attendance.hr_attendance_action_kiosk_mode' && this.next_action.tag != 'hr_attendance_kiosk_mode') {
+ this.activeBarcode = false;
+ }
+
+ this.attendance = action.attendance;
+ // We receive the check in/out times in UTC
+ // This widget only deals with display, which should be in browser's TimeZone
+ this.attendance.check_in = this.attendance.check_in && moment.utc(this.attendance.check_in).local();
+ this.attendance.check_out = this.attendance.check_out && moment.utc(this.attendance.check_out).local();
+ this.previous_attendance_change_date = action.previous_attendance_change_date && moment.utc(action.previous_attendance_change_date).local();
+
+ // check in/out times displayed in the greeting message template.
+ this.format_time = time.getLangTimeFormat();
+ this.attendance.check_in_time = this.attendance.check_in && this.attendance.check_in.format(this.format_time);
+ this.attendance.check_out_time = this.attendance.check_out && this.attendance.check_out.format(this.format_time);
+
+ if (action.hours_today) {
+ var duration = moment.duration(action.hours_today, "hours");
+ this.hours_today = duration.hours() + ' hours, ' + duration.minutes() + ' minutes';
+ }
+
+ this.employee_name = action.employee_name;
+ this.attendanceBarcode = action.barcode;
+ },
+
+ start: function() {
+ if (this.attendance) {
+ this.attendance.check_out ? this.farewell_message() : this.welcome_message();
+ }
+ if (this.activeBarcode) {
+ core.bus.on('barcode_scanned', this, this._onBarcodeScanned);
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ welcome_message: function() {
+ var self = this;
+ var now = this.attendance.check_in.clone();
+ this.return_to_main_menu = setTimeout( function() { self.do_action(self.next_action, {clear_breadcrumbs: true}); }, 5000);
+
+ if (now.hours() < 5) {
+ this.$('.o_hr_attendance_message_message').append(_t("Good night"));
+ } else if (now.hours() < 12) {
+ if (now.hours() < 8 && Math.random() < 0.3) {
+ if (Math.random() < 0.75) {
+ this.$('.o_hr_attendance_message_message').append(_t("The early bird catches the worm"));
+ } else {
+ this.$('.o_hr_attendance_message_message').append(_t("First come, first served"));
+ }
+ } else {
+ this.$('.o_hr_attendance_message_message').append(_t("Good morning"));
+ }
+ } else if (now.hours() < 17){
+ this.$('.o_hr_attendance_message_message').append(_t("Good afternoon"));
+ } else if (now.hours() < 23){
+ this.$('.o_hr_attendance_message_message').append(_t("Good evening"));
+ } else {
+ this.$('.o_hr_attendance_message_message').append(_t("Good night"));
+ }
+ if(this.previous_attendance_change_date){
+ var last_check_out_date = this.previous_attendance_change_date.clone();
+ if(now - last_check_out_date > 24*7*60*60*1000){
+ this.$('.o_hr_attendance_random_message').html(_t("Glad to have you back, it's been a while!"));
+ } else {
+ if(Math.random() < 0.02){
+ this.$('.o_hr_attendance_random_message').html(_t("If a job is worth doing, it is worth doing well!"));
+ }
+ }
+ }
+ },
+
+ farewell_message: function() {
+ var self = this;
+ var now = this.attendance.check_out.clone();
+ this.return_to_main_menu = setTimeout( function() { self.do_action(self.next_action, {clear_breadcrumbs: true}); }, 5000);
+
+ if(this.previous_attendance_change_date){
+ var last_check_in_date = this.previous_attendance_change_date.clone();
+ if(now - last_check_in_date > 1000*60*60*12){
+ this.$('.o_hr_attendance_warning_message').show().append(_t("<b>Warning! Last check in was over 12 hours ago.</b><br/>If this isn't right, please contact Human Resource staff"));
+ clearTimeout(this.return_to_main_menu);
+ this.activeBarcode = false;
+ } else if(now - last_check_in_date > 1000*60*60*8){
+ this.$('.o_hr_attendance_random_message').html(_t("Another good day's work! See you soon!"));
+ }
+ }
+
+ if (now.hours() < 12) {
+ this.$('.o_hr_attendance_message_message').append(_t("Have a good day!"));
+ } else if (now.hours() < 14) {
+ this.$('.o_hr_attendance_message_message').append(_t("Have a nice lunch!"));
+ if (Math.random() < 0.05) {
+ this.$('.o_hr_attendance_random_message').html(_t("Eat breakfast as a king, lunch as a merchant and supper as a beggar"));
+ } else if (Math.random() < 0.06) {
+ this.$('.o_hr_attendance_random_message').html(_t("An apple a day keeps the doctor away"));
+ }
+ } else if (now.hours() < 17) {
+ this.$('.o_hr_attendance_message_message').append(_t("Have a good afternoon"));
+ } else {
+ if (now.hours() < 18 && Math.random() < 0.2) {
+ this.$('.o_hr_attendance_message_message').append(_t("Early to bed and early to rise, makes a man healthy, wealthy and wise"));
+ } else {
+ this.$('.o_hr_attendance_message_message').append(_t("Have a good evening"));
+ }
+ }
+ },
+
+ _onBarcodeScanned: function(barcode) {
+ var self = this;
+ if (this.attendanceBarcode !== barcode){
+ if (this.return_to_main_menu) { // in case of multiple scans in the greeting message view, delete the timer, a new one will be created.
+ clearTimeout(this.return_to_main_menu);
+ }
+ core.bus.off('barcode_scanned', this, this._onBarcodeScanned);
+ this._rpc({
+ model: 'hr.employee',
+ method: 'attendance_scan',
+ args: [barcode, ],
+ })
+ .then(function (result) {
+ if (result.action) {
+ self.do_action(result.action);
+ } else if (result.warning) {
+ self.do_warn(result.warning);
+ setTimeout( function() { self.do_action(self.next_action, {clear_breadcrumbs: true}); }, 5000);
+ }
+ }, function () {
+ setTimeout( function() { self.do_action(self.next_action, {clear_breadcrumbs: true}); }, 5000);
+ });
+ }
+ },
+
+ destroy: function () {
+ core.bus.off('barcode_scanned', this, this._onBarcodeScanned);
+ clearTimeout(this.return_to_main_menu);
+ this._super.apply(this, arguments);
+ },
+});
+
+core.action_registry.add('hr_attendance_greeting_message', GreetingMessage);
+
+return GreetingMessage;
+
+});
diff --git a/addons/hr_attendance/static/src/js/kiosk_confirm.js b/addons/hr_attendance/static/src/js/kiosk_confirm.js
new file mode 100644
index 00000000..8262b24c
--- /dev/null
+++ b/addons/hr_attendance/static/src/js/kiosk_confirm.js
@@ -0,0 +1,94 @@
+odoo.define('hr_attendance.kiosk_confirm', function (require) {
+"use strict";
+
+var AbstractAction = require('web.AbstractAction');
+var core = require('web.core');
+var field_utils = require('web.field_utils');
+var QWeb = core.qweb;
+
+
+var KioskConfirm = AbstractAction.extend({
+ events: {
+ "click .o_hr_attendance_back_button": function () { this.do_action(this.next_action, {clear_breadcrumbs: true}); },
+ "click .o_hr_attendance_sign_in_out_icon": _.debounce(function () {
+ var self = this;
+ this._rpc({
+ model: 'hr.employee',
+ method: 'attendance_manual',
+ args: [[this.employee_id], this.next_action],
+ })
+ .then(function(result) {
+ if (result.action) {
+ self.do_action(result.action);
+ } else if (result.warning) {
+ self.do_warn(result.warning);
+ }
+ });
+ }, 200, true),
+ 'click .o_hr_attendance_pin_pad_button_0': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 0); },
+ 'click .o_hr_attendance_pin_pad_button_1': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 1); },
+ 'click .o_hr_attendance_pin_pad_button_2': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 2); },
+ 'click .o_hr_attendance_pin_pad_button_3': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 3); },
+ 'click .o_hr_attendance_pin_pad_button_4': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 4); },
+ 'click .o_hr_attendance_pin_pad_button_5': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 5); },
+ 'click .o_hr_attendance_pin_pad_button_6': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 6); },
+ 'click .o_hr_attendance_pin_pad_button_7': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 7); },
+ 'click .o_hr_attendance_pin_pad_button_8': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 8); },
+ 'click .o_hr_attendance_pin_pad_button_9': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 9); },
+ 'click .o_hr_attendance_pin_pad_button_C': function() { this.$('.o_hr_attendance_PINbox').val(''); },
+ 'click .o_hr_attendance_pin_pad_button_ok': _.debounce(function() {
+ var self = this;
+ this.$('.o_hr_attendance_pin_pad_button_ok').attr("disabled", "disabled");
+ this._rpc({
+ model: 'hr.employee',
+ method: 'attendance_manual',
+ args: [[this.employee_id], this.next_action, this.$('.o_hr_attendance_PINbox').val()],
+ })
+ .then(function(result) {
+ if (result.action) {
+ self.do_action(result.action);
+ } else if (result.warning) {
+ self.do_warn(result.warning);
+ self.$('.o_hr_attendance_PINbox').val('');
+ setTimeout( function() { self.$('.o_hr_attendance_pin_pad_button_ok').removeAttr("disabled"); }, 500);
+ }
+ });
+ }, 200, true),
+ },
+
+ init: function (parent, action) {
+ this._super.apply(this, arguments);
+ this.next_action = 'hr_attendance.hr_attendance_action_kiosk_mode';
+ this.employee_id = action.employee_id;
+ this.employee_name = action.employee_name;
+ this.employee_state = action.employee_state;
+ this.employee_hours_today = field_utils.format.float_time(action.employee_hours_today);
+ },
+
+ start: function () {
+ var self = this;
+ this.getSession().user_has_group('hr_attendance.group_hr_attendance_use_pin').then(function(has_group){
+ self.use_pin = has_group;
+ self.$el.html(QWeb.render("HrAttendanceKioskConfirm", {widget: self}));
+ self.start_clock();
+ });
+ return self._super.apply(this, arguments);
+ },
+
+ start_clock: function () {
+ this.clock_start = setInterval(function() {this.$(".o_hr_attendance_clock").text(new Date().toLocaleTimeString(navigator.language, {hour: '2-digit', minute:'2-digit', second:'2-digit'}));}, 500);
+ // First clock refresh before interval to avoid delay
+ this.$(".o_hr_attendance_clock").show().text(new Date().toLocaleTimeString(navigator.language, {hour: '2-digit', minute:'2-digit', second:'2-digit'}));
+ },
+
+ destroy: function () {
+ clearInterval(this.clock_start);
+ this._super.apply(this, arguments);
+ },
+});
+
+core.action_registry.add('hr_attendance_kiosk_confirm', KioskConfirm);
+
+return KioskConfirm;
+
+});
diff --git a/addons/hr_attendance/static/src/js/kiosk_mode.js b/addons/hr_attendance/static/src/js/kiosk_mode.js
new file mode 100644
index 00000000..6ce0c026
--- /dev/null
+++ b/addons/hr_attendance/static/src/js/kiosk_mode.js
@@ -0,0 +1,85 @@
+odoo.define('hr_attendance.kiosk_mode', function (require) {
+"use strict";
+
+var AbstractAction = require('web.AbstractAction');
+var ajax = require('web.ajax');
+var core = require('web.core');
+var Session = require('web.session');
+
+var QWeb = core.qweb;
+
+
+var KioskMode = AbstractAction.extend({
+ events: {
+ "click .o_hr_attendance_button_employees": function() {
+ this.do_action('hr_attendance.hr_employee_attendance_action_kanban', {
+ additional_context: {'no_group_by': true},
+ });
+ },
+ },
+
+ start: function () {
+ var self = this;
+ core.bus.on('barcode_scanned', this, this._onBarcodeScanned);
+ self.session = Session;
+ var def = this._rpc({
+ model: 'res.company',
+ method: 'search_read',
+ args: [[['id', '=', this.session.company_id]], ['name']],
+ })
+ .then(function (companies){
+ self.company_name = companies[0].name;
+ self.company_image_url = self.session.url('/web/image', {model: 'res.company', id: self.session.company_id, field: 'logo',});
+ self.$el.html(QWeb.render("HrAttendanceKioskMode", {widget: self}));
+ self.start_clock();
+ });
+ // Make a RPC call every day to keep the session alive
+ self._interval = window.setInterval(this._callServer.bind(this), (60*60*1000*24));
+ return Promise.all([def, this._super.apply(this, arguments)]);
+ },
+
+ _onBarcodeScanned: function(barcode) {
+ var self = this;
+ core.bus.off('barcode_scanned', this, this._onBarcodeScanned);
+ this._rpc({
+ model: 'hr.employee',
+ method: 'attendance_scan',
+ args: [barcode, ],
+ })
+ .then(function (result) {
+ if (result.action) {
+ self.do_action(result.action);
+ } else if (result.warning) {
+ self.do_warn(result.warning);
+ core.bus.on('barcode_scanned', self, self._onBarcodeScanned);
+ }
+ }, function () {
+ core.bus.on('barcode_scanned', self, self._onBarcodeScanned);
+ });
+ },
+
+ start_clock: function() {
+ this.clock_start = setInterval(function() {this.$(".o_hr_attendance_clock").text(new Date().toLocaleTimeString(navigator.language, {hour: '2-digit', minute:'2-digit', second:'2-digit'}));}, 500);
+ // First clock refresh before interval to avoid delay
+ this.$(".o_hr_attendance_clock").show().text(new Date().toLocaleTimeString(navigator.language, {hour: '2-digit', minute:'2-digit', second:'2-digit'}));
+ },
+
+ destroy: function () {
+ core.bus.off('barcode_scanned', this, this._onBarcodeScanned);
+ clearInterval(this.clock_start);
+ clearInterval(this._interval);
+ this._super.apply(this, arguments);
+ },
+
+ _callServer: function () {
+ // Make a call to the database to avoid the auto close of the session
+ return ajax.rpc("/hr_attendance/kiosk_keepalive", {});
+ },
+
+});
+
+core.action_registry.add('hr_attendance_kiosk_mode', KioskMode);
+
+return KioskMode;
+
+});
diff --git a/addons/hr_attendance/static/src/js/my_attendances.js b/addons/hr_attendance/static/src/js/my_attendances.js
new file mode 100644
index 00000000..91acb7f1
--- /dev/null
+++ b/addons/hr_attendance/static/src/js/my_attendances.js
@@ -0,0 +1,56 @@
+odoo.define('hr_attendance.my_attendances', function (require) {
+"use strict";
+
+var AbstractAction = require('web.AbstractAction');
+var core = require('web.core');
+var field_utils = require('web.field_utils');
+
+
+var MyAttendances = AbstractAction.extend({
+ contentTemplate: 'HrAttendanceMyMainMenu',
+ events: {
+ "click .o_hr_attendance_sign_in_out_icon": _.debounce(function() {
+ this.update_attendance();
+ }, 200, true),
+ },
+
+ willStart: function () {
+ var self = this;
+
+ var def = this._rpc({
+ model: 'hr.employee',
+ method: 'search_read',
+ args: [[['user_id', '=', this.getSession().uid]], ['attendance_state', 'name', 'hours_today']],
+ })
+ .then(function (res) {
+ self.employee = res.length && res[0];
+ if (res.length) {
+ self.hours_today = field_utils.format.float_time(self.employee.hours_today);
+ }
+ });
+
+ return Promise.all([def, this._super.apply(this, arguments)]);
+ },
+
+ update_attendance: function () {
+ var self = this;
+ this._rpc({
+ model: 'hr.employee',
+ method: 'attendance_manual',
+ args: [[self.employee.id], 'hr_attendance.hr_attendance_action_my_attendances'],
+ })
+ .then(function(result) {
+ if (result.action) {
+ self.do_action(result.action);
+ } else if (result.warning) {
+ self.do_warn(result.warning);
+ }
+ });
+ },
+});
+
+core.action_registry.add('hr_attendance_my_attendances', MyAttendances);
+
+return MyAttendances;
+
+});
diff --git a/addons/hr_attendance/static/src/js/time_widget.js b/addons/hr_attendance/static/src/js/time_widget.js
new file mode 100644
index 00000000..eabfcfbb
--- /dev/null
+++ b/addons/hr_attendance/static/src/js/time_widget.js
@@ -0,0 +1,19 @@
+odoo.define('hr_attendance.widget', function (require) {
+ "use strict";
+
+ var basic_fields = require('web.basic_fields');
+ var field_registry = require('web.field_registry');
+
+ var RelativeTime = basic_fields.FieldDateTime.extend({
+ _formatValue: function (val) {
+ if (!(val && val._isAMomentObject)) {
+ return;
+ }
+ return val.fromNow(true);
+ },
+ });
+
+ field_registry.add('relative_time', RelativeTime);
+
+ return RelativeTime;
+}); \ No newline at end of file
diff --git a/addons/hr_attendance/static/src/scss/hr_attendance.scss b/addons/hr_attendance/static/src/scss/hr_attendance.scss
new file mode 100644
index 00000000..4bd2b65d
--- /dev/null
+++ b/addons/hr_attendance/static/src/scss/hr_attendance.scss
@@ -0,0 +1,228 @@
+
+.o_kanban_view #oe_hr_attendance_status {
+ margin-right: .3em;
+}
+
+#oe_hr_attendance_status {
+ color: $o-brand-secondary;
+ &.oe_hr_attendance_status_green {
+ color: theme-color('success');
+ }
+ &.oe_hr_attendance_status_orange {
+ color: theme-color('warning');
+ }
+}
+
+.o_hr_attendance_kiosk_backdrop {
+ @include o-position-absolute(0,0,0,0);
+ background-color: fade-out(black, 0.7);
+}
+
+.o_hr_attendance_clock {
+ display: none;
+ position: relative;
+ width: 100%;
+ padding: 0.5em;
+ background-color: fade-out(black, 0.7);
+ font: normal 1.2em $font-family-monospace;
+ color: white;
+
+ @include media-breakpoint-up(md) {
+ @include o-position-absolute(20px, 20px);
+ width: auto;
+ padding: 3px 10px 3px 10px;
+ border-radius: 3px;
+ font-size: 1.5em;
+ }
+}
+
+.o_hr_attendance_kiosk_mode_container {
+ display: flex;
+ flex-flow: column nowrap;
+ justify-content: flex-start;
+ align-items: center;
+ @include o-position-absolute(0, 0, 0, 0);
+
+ @include media-breakpoint-up(md) {
+ justify-content: center;
+ }
+}
+
+.o_hr_attendance_kiosk_mode {
+ width: 100%;
+ text-align: center;
+ position: relative;
+ background-color: fade-out(white, 0.1);
+ &.o_barcode_main {
+ font-family: 'Lato', sans-serif;
+ width: 100%;
+ text-align: center;
+ background-color: #fff;
+ padding: 3em;
+ img {
+ width: 115px;
+ height: 60px;
+ }
+ .o_hr_attendance_kiosk_welcome_row img {
+ max-width: inherit;
+ }
+ @include media-breakpoint-down(sm) {
+ h2 {
+ margin-left: -10px;
+ margin-right: -10px;
+ }
+ }
+ }
+
+ @include media-breakpoint-down(sm) {
+ overflow: hidden;
+ padding: 0 2em 2em;
+ flex: 1 0 auto;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ }
+
+ @include media-breakpoint-up(md) {
+ padding: 2em 4em;
+ width: auto;
+ max-width: 550px;
+ width: 100%;
+ border-radius: 0.2em;
+ font-size: 1.2em;
+ animation: fadeInDownSmall .3s;
+ }
+
+ .o_hr_attendance_kiosk_welcome_row {
+ @include media-breakpoint-up(md) {
+ display: flex;
+ align-items: center;
+ }
+
+ img {
+ max-width: 80px;
+ }
+ }
+
+ .o_hr_attendance_sign_in_out_icon {
+ cursor: pointer;
+ margin: 0.1em 0 0.1em;
+ padding: 0.15em 0.3em;
+ border-radius: .1em;
+ box-shadow: inset 0 -3px 0 fade-out(black, 0.7);
+
+ &.btn-secondary:hover {
+ color: $o-brand-primary;
+ }
+ }
+
+ .o_hr_attendance_back_button {
+ .visible-xs{
+ background: gray('200');
+ margin: 0 -2em;
+ .fa {
+ @include o-position-absolute(0.75em);
+ margin-left: -1.5em
+ }
+ }
+
+ .d-none.d-md-inline-block.btn-secondary {
+ transform: translate(-50%, -50%);
+ @include o-position-absolute(0, $left:0);
+ width: 2em;
+ height: 2em;
+ border-radius: 50%;
+ padding-left: 14px;
+ line-height: 1.5em;
+ text-align: left;
+ color: $body-color;
+ font-size: 1.6em;
+ z-index: 1;
+ box-shadow: inset 0 0 0 1px fade-out(black, 0.8);
+ }
+ }
+
+ .btn-secondary{
+ box-shadow: inset 0 0 0 1px fade-out(black, 0.9);
+ color: $headings-color;
+ }
+
+ .o_hr_attendance_user_badge {
+ background: linear-gradient(to right bottom, #77717e, #c9a8a9);
+
+ img {
+ width: 50px;
+ height: 50px;
+ background: white;
+ border: 1px solid #d7d7d7;
+ }
+
+ @include media-breakpoint-down(sm) {
+ margin: 1em -2em 0;
+ background: transparent;
+ }
+
+ @include media-breakpoint-up(md) {
+ margin: 0;
+ height: 90px;
+ border-radius: .2em .2em 0 0;
+ border-top: 1px solid fade-out(white, 0.8);
+ @include o-position-absolute(auto, 0, 100%, 0);
+ transform: translateY(3px);
+
+ img {
+ width: 80px;
+ height: 80px;
+ transform: translateX(-50%)translateY(35%);
+ @include o-position-absolute($bottom: 0);
+ }
+ }
+
+ + h1 {
+ margin-top: .4em;
+ }
+ }
+
+ .o_hr_attendance_pin_pad [class*="col-"] {
+ padding: 4px;
+
+ .o_hr_attendance_PINbox {
+ font-size: 2em;
+ border: none;
+ padding: 0 $input-btn-padding-x-lg;
+ background: fade-out(white, 0.6);
+ }
+ }
+
+ .o_hr_attendance_random_message {
+ font-style: italic;
+ }
+
+ .message_demo_barcodes {
+ font-size: 0.9em;
+ margin: 0;
+ }
+
+ img.o_hr_attendance_kiosk_company_image {
+ overflow:hidden; // prevent margins colapsing with h1
+ margin: 1rem 0 2rem;
+ width: 200px;
+ }
+
+ p {
+ text-align: left;
+ margin: 3rem 0;
+ }
+
+ > button {
+ font-size: 1.2em;
+ margin-bottom: 2rem;
+ width: 100%;
+ font-weight: 400;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+}
diff --git a/addons/hr_attendance/static/src/xml/attendance.xml b/addons/hr_attendance/static/src/xml/attendance.xml
new file mode 100644
index 00000000..04800ed8
--- /dev/null
+++ b/addons/hr_attendance/static/src/xml/attendance.xml
@@ -0,0 +1,145 @@
+<?xml version="1.0" encoding="utf-8"?>
+<template xml:space="preserve">
+ <t t-name="PresenceIndicator">
+ <div id="oe_hr_attendance_status" class="fa fa-circle" role="img" aria-label="Available" title="Available">
+ </div>
+ </t>
+
+ <t t-name="HrAttendanceKioskMode">
+ <div class="o_hr_attendance_kiosk_mode_container o_home_menu_background">
+ <span class="o_hr_attendance_kiosk_backdrop"/>
+ <div class="o_hr_attendance_clock text-center"/>
+ <div class="o_hr_attendance_kiosk_mode o_barcode_main">
+ <h2><small>Welcome to</small> <t t-esc="widget.company_name"/></h2>
+ <img t-attf-src="{{widget.company_image_url}}" alt="Company Logo" class="o_hr_attendance_kiosk_company_image"/>
+ <div class="row o_hr_attendance_kiosk_welcome_row">
+ <div class="col-sm-5 mt16">
+ <img class="img img-fluid d-block mx-auto" src="/barcodes/static/img/barcode.png"/>
+ <h5 class="mt8 mb0 text-muted">Scan your badge</h5>
+ </div>
+ <div class="col-sm-2 mt32">
+ <h4 class="mt0 mb8"><i>or</i></h4>
+ </div>
+ <div class="col-sm-5 mt16">
+ <button class="o_hr_attendance_button_employees btn btn-primary mb16">
+ <div class="mb16 mt16">Identify Manually</div>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+
+ <t t-name="HrAttendanceMyMainMenu">
+ <div class="o_hr_attendance_kiosk_mode_container o_home_menu_background">
+ <span class="o_hr_attendance_kiosk_backdrop"/>
+ <div class="o_hr_attendance_clock text-center"/>
+ <div class="o_hr_attendance_kiosk_mode">
+ <t t-set="checked_in" t-value="widget.employee.attendance_state=='checked_in'"/>
+ <t t-if="widget.employee">
+ <div class="o_hr_attendance_user_badge o_home_menu_background">
+ <img class="img rounded-circle" t-attf-src="/web/image?model=hr.employee&amp;field=image_128&amp;id=#{widget.employee.id}" t-att-title="widget.employee.name" t-att-alt="widget.employee.name"/>
+ </div>
+ <h1 class="mb8"><t t-esc="widget.employee.name"/></h1>
+ <h3 class="mt8 mb24"><t t-if="!checked_in">Welcome!</t><t t-else="">Want to check out?</t></h3>
+ <h4 class="mt0 mb0 text-muted" t-if="checked_in">Today's work hours: <span t-esc="widget.hours_today"/></h4>
+ <a class="fa fa-7x o_hr_attendance_sign_in_out_icon fa-sign-out btn-warning" t-if="checked_in" aria-label="Sign out" title="Sign out"/>
+ <a class="fa fa-7x o_hr_attendance_sign_in_out_icon fa-sign-in btn-secondary" t-if="!checked_in" aria-label="Sign in" title="Sign in"/>
+ <h3 class="mt0 mb0 text-muted">Click to <b t-if="checked_in">check out</b><b t-if="!checked_in">check in</b></h3>
+ </t>
+ <t t-else="">
+ Warning : Your user should be linked to an employee to use attendance. Please contact your administrator.
+ </t>
+ </div>
+ </div>
+ </t>
+
+ <t t-name="HrAttendanceKioskConfirm">
+ <div class="o_hr_attendance_kiosk_mode_container o_home_menu_background">
+ <span class="o_hr_attendance_kiosk_backdrop"/>
+ <div class="o_hr_attendance_clock text-center"/>
+ <div class="o_hr_attendance_kiosk_mode">
+ <t t-set="checked_in" t-value="widget.employee_state=='checked_in'"/>
+ <div class="o_hr_attendance_back_button">
+ <span class="btn btn-secondary btn-lg d-block d-md-none"><i class="fa fa-chevron-left mr8"/> Go back</span>
+ <span class="btn btn-secondary d-none d-md-inline-block"><i class="fa fa-chevron-left" role="img" aria-label="Go back" title="Go back"/></span>
+ </div>
+ <t t-if="widget.employee_id">
+ <div class="o_hr_attendance_user_badge o_home_menu_background">
+ <img class="img rounded-circle" t-attf-src="/web/image?model=hr.employee&amp;field=image_128&amp;id=#{widget.employee_id}" t-att-title="widget.employee_name" t-att-alt="widget.employee_name"/>
+ </div>
+ <h1 class="mb8"><t t-esc="widget.employee_name"/></h1>
+ <h3 class="mt8 mb24"><t t-if="!checked_in">Welcome!</t><t t-else="">Want to check out?</t></h3>
+ <h4 class="mt0 mb0 text-muted" t-if="checked_in">Today's work hours: <span t-esc="widget.employee_hours_today"/></h4>
+ <t t-if="!widget.use_pin">
+ <a class="fa fa-7x o_hr_attendance_sign_in_out_icon fa-sign-out btn-warning" t-if="checked_in" aria-label="Sign out" title="Sign out"/>
+ <a class="fa fa-7x o_hr_attendance_sign_in_out_icon fa-sign-in btn-secondary" t-if="!checked_in" aria-label="Sign in" title="Sign in"/>
+ <h3 class="mt0 mb0 text-muted">Click to <b t-if="checked_in">check out</b><b t-else="">check in</b></h3>
+ </t>
+ <t t-else="">
+ <h3 class="mt0 mb0 text-muted">Please enter your PIN to <b t-if="checked_in">check out</b><b t-else="">check in</b></h3>
+ <div class="row">
+ <div class="col-md-8 offset-md-2 o_hr_attendance_pin_pad">
+ <div class="row" >
+ <div class="col-12 mb8 mt8"><input class="o_hr_attendance_PINbox text-center" type="password" disabled="true"/></div>
+ </div>
+ <div class="row">
+ <t t-foreach="['1', '2', '3', '4', '5', '6', '7', '8', '9', ['C', 'btn-warning'], '0', ['ok', 'btn-primary']]" t-as="btn_name">
+ <div class="col-4 mb4">
+ <a t-attf-class="btn {{btn_name[1]? btn_name[1] : 'btn-secondary'}} btn-block btn-lg {{ 'o_hr_attendance_pin_pad_button_' + btn_name[0] }}"><t t-esc="btn_name[0]"/></a>
+ </div>
+ </t>
+ </div>
+ </div>
+ </div>
+ </t>
+ </t>
+ <div t-else="" class="alert alert-danger" role="alert">
+ <b>Error: could not find corresponding employee.</b><br/>Please return to the main menu.
+ </div>
+ <a role="button" class="oe_attendance_sign_in_out" aria-label="Sign out" title="Sign out"/>
+ </div>
+ </div>
+ </t>
+
+ <t t-name="HrAttendanceGreetingMessage">
+ <div class="o_hr_attendance_kiosk_mode_container o_home_menu_background">
+ <span class="o_hr_attendance_kiosk_backdrop"/>
+ <div class="o_hr_attendance_clock text-center"/>
+ <div class="o_hr_attendance_kiosk_mode">
+ <t t-if="widget.attendance">
+ <div class="o_hr_attendance_user_badge o_home_menu_background">
+ <img class="img rounded-circle" t-attf-src="/web/image?model=hr.employee&amp;field=image_128&amp;id=#{widget.attendance.employee_id[0]}" t-att-title="widget.employee_name" t-att-alt="widget.employee_name"/>
+ </div>
+ <t t-if="widget.attendance.check_out">
+ <h1 class="mb0">Goodbye <t t-esc="widget.employee_name"/>!</h1>
+ <h2 class="o_hr_attendance_message_message mt4 mb24"/>
+ <div class="alert alert-info h2 mt0" role="status">
+ Checked out at <b><t t-esc="widget.attendance.check_out_time"/></b>
+ <br/><b><t t-esc="widget.hours_today"/></b>
+ </div>
+ <h3 class="o_hr_attendance_random_message mb24"/>
+ <div class="o_hr_attendance_warning_message mt24 alert alert-warning" style="display:none" role="alert"/>
+ </t>
+ <t t-else="">
+ <h1 class="mb0">Welcome <t t-esc="widget.employee_name"/>!</h1>
+ <h2 class="o_hr_attendance_message_message mt4 mb24"/>
+ <div class="alert alert-info h2 mt0" role="status">
+ Checked in at <b><t t-esc="widget.attendance.check_in_time"/></b>
+ </div>
+ <h3 class="o_hr_attendance_random_message mb24"/>
+ <div class="o_hr_attendance_warning_message mt24 alert alert-warning" style="display:none" role="alert"/>
+ </t>
+ <button class="o_hr_attendance_button_dismiss btn btn-primary btn-lg">
+ <span class="text-capitalize" t-if="widget.attendance.check_out">Goodbye</span>
+ <span class="text-capitalize" t-else="">OK</span>
+ </button>
+ </t>
+ <t t-else="">
+ <div class="alert alert-warning" role="alert">Invalid request, please return to the main menu.</div>
+ <button class="o_hr_attendance_button_dismiss btn btn-secondary btn-lg">Go back</button>
+ </t>
+ </div>
+ </div>
+ </t>
+</template>
diff --git a/addons/hr_attendance/static/tests/hr_attendance_tests.js b/addons/hr_attendance/static/tests/hr_attendance_tests.js
new file mode 100644
index 00000000..72e3ae93
--- /dev/null
+++ b/addons/hr_attendance/static/tests/hr_attendance_tests.js
@@ -0,0 +1,189 @@
+odoo.define('hr_attendance.tests', function (require) {
+"use strict";
+
+var testUtils = require('web.test_utils');
+var core = require('web.core');
+
+var MyAttendances = require('hr_attendance.my_attendances');
+var KioskMode = require('hr_attendance.kiosk_mode');
+var GreetingMessage = require('hr_attendance.greeting_message');
+
+
+QUnit.module('HR Attendance', {
+ beforeEach: function () {
+ this.data = {
+ 'hr.employee': {
+ fields: {
+ name: {string: 'Name', type: 'char'},
+ attendance_state: {
+ string: 'State',
+ type: 'selection',
+ selection: [['checked_in', "In"], ['checked_out', "Out"]],
+ default: 1,
+ },
+ user_id: {string: 'user ID', type: 'integer'},
+ barcode: {string:'barcode', type: 'integer'},
+ hours_today: {string:'Hours today', type: 'float'},
+ },
+ records: [{
+ id: 1,
+ name: "Employee A",
+ attendance_state: 'checked_out',
+ user_id: 1,
+ barcode: 1,
+ },
+ {
+ id: 2,
+ name: "Employee B",
+ attendance_state: 'checked_out',
+ user_id: 2,
+ barcode: 2,
+ }],
+ },
+ 'res.company': {
+ fields: {
+ name: {string: 'Name', type: 'char'},
+ },
+ records: [{
+ id: 1,
+ name: "Company A",
+ }],
+ },
+ };
+ },
+}, function () {
+ QUnit.module('My attendances (client action)');
+
+ QUnit.test('simple rendering', async function (assert) {
+ assert.expect(1);
+
+ var $target = $('#qunit-fixture');
+ var clientAction = new MyAttendances(null, {});
+ await testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.data,
+ session: {
+ uid: 1,
+ },
+ });
+ await clientAction.appendTo($target);
+
+ assert.strictEqual(clientAction.$('.o_hr_attendance_kiosk_mode h1').text(), 'Employee A',
+ "should have rendered the client action without crashing");
+
+ clientAction.destroy();
+ });
+
+ QUnit.test('Attendance Kiosk Mode Test', async function (assert) {
+ assert.expect(2);
+
+ var $target = $('#qunit-fixture');
+ var self = this;
+ var rpcCount = 0;
+ var clientAction = new KioskMode(null, {});
+ await testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.data,
+ session: {
+ uid: 1,
+ company_id: 1,
+ },
+ mockRPC: function(route, args) {
+ if (args.method === 'attendance_scan' && args.model === 'hr.employee') {
+
+ rpcCount++;
+ return Promise.resolve(self.data['hr.employee'].records[0]);
+ }
+ return this._super(route, args);
+ },
+ });
+ await clientAction.appendTo($target);
+ core.bus.trigger('barcode_scanned', 1);
+ core.bus.trigger('barcode_scanned', 1);
+ assert.strictEqual(rpcCount, 1, 'RPC call should have been done only once.');
+
+ core.bus.trigger('barcode_scanned', 2);
+ assert.strictEqual(rpcCount, 1, 'RPC call should have been done only once.');
+
+ clientAction.destroy();
+ });
+
+ QUnit.test('Attendance Greeting Message Test', async function (assert) {
+ assert.expect(10);
+
+ var $target = $('#qunit-fixture');
+ var self = this;
+ var rpcCount = 0;
+
+ var clientActions = [];
+ async function createGreetingMessage (target, barcode){
+ var action = {
+ attendance: {
+ check_in: "2018-09-20 13:41:13",
+ employee_id: [barcode],
+ },
+ next_action: "hr_attendance.hr_attendance_action_kiosk_mode",
+ barcode: barcode,
+ };
+ var clientAction = new GreetingMessage(null, action);
+ await testUtils.mock.addMockEnvironment(clientAction, {
+ data: self.data,
+ session: {
+ uid: 1,
+ company_id: 1,
+ },
+ mockRPC: function(route, args) {
+ if (args.method === 'attendance_scan' && args.model === 'hr.employee') {
+ rpcCount++;
+ action.attendance.employee_id = [args.args[0], 'Employee'];
+ /*
+ if rpc have been made, a new instance is created to simulate the same behaviour
+ as functional flow.
+ */
+ createGreetingMessage (target, args.args[0]);
+ return Promise.resolve({action: action});
+ }
+ return this._super(route, args);
+ },
+ });
+ await clientAction.appendTo(target);
+
+ clientActions.push(clientAction);
+ }
+
+ // init - mock coming from kiosk
+ await createGreetingMessage ($target, 1);
+ await testUtils.nextMicrotaskTick();
+ assert.strictEqual(clientActions.length, 1, 'Number of clientAction must = 1.');
+
+ core.bus.trigger('barcode_scanned', 1);
+ /*
+ As action is given when instantiate GreetingMessage, we simulate that we come from the KioskMode
+ So rescanning the same barcode won't lead to another RPC.
+ */
+ assert.strictEqual(clientActions.length, 1, 'Number of clientActions must = 1.');
+ assert.strictEqual(rpcCount, 0, 'RPC call should not have been done.');
+
+ core.bus.trigger('barcode_scanned', 2);
+ await testUtils.nextTick();
+ assert.strictEqual(clientActions.length, 2, 'Number of clientActions must = 2.');
+ assert.strictEqual(rpcCount, 1, 'RPC call should have been done only once.');
+ core.bus.trigger('barcode_scanned', 2);
+ await testUtils.nextMicrotaskTick();
+ assert.strictEqual(clientActions.length, 2, 'Number of clientActions must = 2.');
+ assert.strictEqual(rpcCount, 1, 'RPC call should have been done only once.');
+
+ core.bus.trigger('barcode_scanned', 1);
+ await testUtils.nextTick();
+ assert.strictEqual(clientActions.length, 3, 'Number of clientActions must = 3.');
+ core.bus.trigger('barcode_scanned', 1);
+ await testUtils.nextMicrotaskTick();
+ assert.strictEqual(clientActions.length, 3, 'Number of clientActions must = 3.');
+ assert.strictEqual(rpcCount, 2, 'RPC call should have been done only twice.');
+
+ _.each(clientActions.reverse(), function(clientAction) {
+ clientAction.destroy();
+ });
+ });
+
+});
+
+});