diff --git a/hr_attendance_work_entry/__init__.py b/hr_attendance_work_entry/__init__.py new file mode 100644 index 00000000..26c033a8 --- /dev/null +++ b/hr_attendance_work_entry/__init__.py @@ -0,0 +1,15 @@ +from . import models + + +def attn_payroll_pre_init_hook(cr): + """ + This module installs a Work Entry Type with code "ATTN" + If you have undergone a migration (either for this module + or even your own Payslip Work Entry lines with code "ATTN") + then the uniqueness constraint will prevent this module + from installing. + """ + cr.execute("UPDATE hr_work_entry_type " + "SET code = 'ATTN-PRE-INSTALL' " + "WHERE code = 'ATTN';" + ) diff --git a/hr_attendance_work_entry/__manifest__.py b/hr_attendance_work_entry/__manifest__.py new file mode 100755 index 00000000..9d04921b --- /dev/null +++ b/hr_attendance_work_entry/__manifest__.py @@ -0,0 +1,33 @@ +{ + 'name': 'Attendance Work Entry Type', + 'description': 'Set work types on attendance records.', + 'version': '15.0.1.0.0', + 'website': 'https://hibou.io/', + 'author': 'Hibou Corp. ', + 'license': 'AGPL-3', + 'category': 'Human Resources', + 'depends': [ + 'hr_attendance', + 'hr_work_entry', + ], + 'data': [ + 'data/hr_attendance_work_entry_data.xml', + 'views/attendance_views.xml', + 'views/employee_views.xml', + 'views/work_entry_views.xml', + ], + 'demo': [ + 'data/hr_attendance_work_entry_demo.xml', + ], + 'assets': { + 'web.assets_qweb': [ + 'hr_attendance_work_entry/static/src/xml/hr_attendance.xml', + ], + 'web.assets_backend': [ + 'hr_attendance_work_entry/static/src/js/kiosk_confirm.js', + 'hr_attendance_work_entry/static/src/js/my_attendances.js', + 'hr_attendance_work_entry/static/src/scss/hr_attendances.scss', + ], + }, + 'pre_init_hook': 'attn_payroll_pre_init_hook', +} diff --git a/hr_attendance_work_entry/data/hr_attendance_work_entry_data.xml b/hr_attendance_work_entry/data/hr_attendance_work_entry_data.xml new file mode 100644 index 00000000..ab99a1ca --- /dev/null +++ b/hr_attendance_work_entry/data/hr_attendance_work_entry_data.xml @@ -0,0 +1,17 @@ + + + + + Attendance + ATTN + + checked_in + fa-sign-in + + + + + Work Calendar + + + \ No newline at end of file diff --git a/hr_attendance_work_entry/data/hr_attendance_work_entry_demo.xml b/hr_attendance_work_entry/data/hr_attendance_work_entry_demo.xml new file mode 100644 index 00000000..e88356b2 --- /dev/null +++ b/hr_attendance_work_entry/data/hr_attendance_work_entry_demo.xml @@ -0,0 +1,20 @@ + + + + + Break + ATTN_BREAK + + break + fa-hourglass-1 + + + + Lunch + ATTN_LUNCH + + lunch + fa-coffee + + + \ No newline at end of file diff --git a/hr_attendance_work_entry/models/__init__.py b/hr_attendance_work_entry/models/__init__.py new file mode 100644 index 00000000..1e37c49d --- /dev/null +++ b/hr_attendance_work_entry/models/__init__.py @@ -0,0 +1,3 @@ +from . import attendance +from . import employee +from . import work_entry diff --git a/hr_attendance_work_entry/models/attendance.py b/hr_attendance_work_entry/models/attendance.py new file mode 100644 index 00000000..cd0d35b5 --- /dev/null +++ b/hr_attendance_work_entry/models/attendance.py @@ -0,0 +1,14 @@ +from odoo import api, fields, models + + +class HrAttendance(models.Model): + _inherit = 'hr.attendance' + + work_type_id = fields.Many2one('hr.work.entry.type', string='Work Type', + default=lambda self: self.env.ref('hr_attendance_work_entry.work_input_attendance', + raise_if_not_found=False)) + + @api.model + def gather_attendance_work_types(self): + work_types = self.env['hr.work.entry.type'].sudo().search([('allow_attendance', '=', True)]) + return work_types.read(['id', 'name', 'attendance_icon']) diff --git a/hr_attendance_work_entry/models/employee.py b/hr_attendance_work_entry/models/employee.py new file mode 100644 index 00000000..e57a422b --- /dev/null +++ b/hr_attendance_work_entry/models/employee.py @@ -0,0 +1,61 @@ +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + + +class HrEmployee(models.Model): + _inherit = 'hr.employee' + + attendance_state = fields.Selection(selection_add=[('break', 'Break'), ('lunch', 'Lunch')]) + + @api.depends('last_attendance_id.work_type_id', 'last_attendance_id.check_in', 'last_attendance_id.check_out', 'last_attendance_id') + def _compute_attendance_state(self): + for employee in self: + att = employee.last_attendance_id.sudo() + if not att or att.check_out: + employee.attendance_state = 'checked_out' + elif employee.last_attendance_id.work_type_id.attendance_state: + employee.attendance_state = employee.last_attendance_id.work_type_id.attendance_state + else: + employee.attendance_state = 'checked_in' + + def attendance_manual(self, next_action, entered_pin=None, work_type_id=None): + self = self.with_context(work_type_id=work_type_id) + if not entered_pin: + # fix for pin mode with specific argument order for work_type_id + entered_pin = None + return super(HrEmployee, self).attendance_manual(next_action, entered_pin=entered_pin) + + def _attendance_action_change(self): + """ Check In/Check Out action + Check In: create a new attendance record + Check Out: modify check_out field of appropriate attendance record + """ + self.ensure_one() + action_date = fields.Datetime.now() + work_type_id = self._context.get('work_type_id', False) + + if self.attendance_state == 'checked_out': + vals = { + 'employee_id': self.id, + 'check_in': action_date, + } + if work_type_id: + # if we don't have a work_type_id, we want the default + vals['work_type_id'] = work_type_id + return self.env['hr.attendance'].create(vals) + attendance = self.env['hr.attendance'].search([('employee_id', '=', self.id), ('check_out', '=', False)], limit=1) + if attendance and work_type_id: + # work_type_id is the "next" attendance type + attendance.check_out = action_date + vals = { + 'employee_id': self.id, + 'check_in': action_date, + 'work_type_id': work_type_id, + } + return self.env['hr.attendance'].create(vals) + if attendance: + attendance.check_out = action_date + else: + raise UserError(_('Cannot perform check out on %(empl_name)s, could not find corresponding check in. ' + 'Your attendances have probably been modified manually by human resources.') % {'empl_name': self.sudo().name, }) + return attendance diff --git a/hr_attendance_work_entry/models/work_entry.py b/hr_attendance_work_entry/models/work_entry.py new file mode 100644 index 00000000..73618223 --- /dev/null +++ b/hr_attendance_work_entry/models/work_entry.py @@ -0,0 +1,15 @@ +from odoo import fields, models + + +class HrWorkEntryType(models.Model): + _inherit = 'hr.work.entry.type' + _order = 'sequence, id' + + allow_attendance = fields.Boolean(string='Allow in Attendance') + attendance_icon = fields.Char(string='Attendance Icon', default='fa-sign-in') + attendance_state = fields.Selection([ + # ('checked_out', "Checked out"), # reserved for detecting new punch in + ('checked_in', "Checked in"), + ('break', 'Break'), + ('lunch', 'Lunch'), + ], string='Attendance State', default='checked_in') diff --git a/hr_attendance_work_entry/static/src/js/kiosk_confirm.js b/hr_attendance_work_entry/static/src/js/kiosk_confirm.js new file mode 100644 index 00000000..8919f0e3 --- /dev/null +++ b/hr_attendance_work_entry/static/src/js/kiosk_confirm.js @@ -0,0 +1,71 @@ +odoo.define('hr_attendance_work_entry.kiosk_confirm', function (require) { +"use strict"; + +var core = require('web.core'); +var KioskConfirm = require('hr_attendance.kiosk_confirm'); +var KioskConfirmTyped = KioskConfirm.extend({ + events: _.extend({}, KioskConfirm.prototype.events, { + "click .o_hr_attendance_punch_type": _.debounce(function(e) { + var work_entry_type = $(e.target).data('work-entry-type'); + this.update_attendance(work_entry_type); + }, 200, true), + "click .o_hr_attendance_pin_pad_button_work": _.debounce(function(e) { + var work_entry_type = $(e.target).data('work-entry-type'); + this.update_attendance_pin(work_entry_type); + }, 200, true), + }), + willStart: function () { + var self = this; + + var def = this._rpc({ + model: 'hr.attendance', + method: 'gather_attendance_work_types', + args: []}) + .then(function (res) { + self.work_types = res; + }); + + return Promise.all([def, this._super.apply(this, arguments)]); + }, + update_attendance: function (type) { + var self = this; + this._rpc({ + model: 'hr.employee', + method: 'attendance_manual', + args: [[self.employee_id], this.next_action, false, type], + }) + .then(function(result) { + if (result.action) { + self.do_action(result.action); + } else if (result.warning) { + self.do_warn(result.warning); + } + }); + }, + update_attendance_pin: function (type) { + var self = this; + this.$('.o_hr_attendance_pin_pad_button_ok').attr("disabled", "disabled"); + this.$('.o_hr_attendance_pin_pad_button_work').attr("disabled", "disabled"); + this._rpc({ + model: 'hr.employee', + method: 'attendance_manual', + args: [[this.employee_id], this.next_action, this.$('.o_hr_attendance_PINbox').val(), type], + }) + .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"); + self.$('.o_hr_attendance_pin_pad_button_work').removeAttr("disabled"); + }, 500); + } + }); + }, +}); + +core.action_registry.add('hr_attendance_kiosk_confirm', KioskConfirmTyped); +return KioskConfirmTyped; +}); \ No newline at end of file diff --git a/hr_attendance_work_entry/static/src/js/my_attendances.js b/hr_attendance_work_entry/static/src/js/my_attendances.js new file mode 100644 index 00000000..d9dcf7ac --- /dev/null +++ b/hr_attendance_work_entry/static/src/js/my_attendances.js @@ -0,0 +1,50 @@ +odoo.define('hr_attendance_work_entry.my_attendances', function (require) { +"use strict"; + +var core = require('web.core'); +var MyAttendances = require('hr_attendance.my_attendances'); + +var MyTypedAttendances = MyAttendances.extend({ + events: _.extend({}, MyAttendances.prototype.events, { + "click .o_hr_attendance_punch_type": _.debounce(function(e) { + var work_entry_type = $(e.target).data('work-entry-type'); + this.update_attendance(work_entry_type); + }, 200, true), + }), + + willStart: function () { + var self = this; + + var def = this._rpc({ + model: 'hr.attendance', + method: 'gather_attendance_work_types', + args: []}) + .then(function (res) { + self.work_types = res; + }); + + return Promise.all([def, this._super.apply(this, arguments)]); + }, + + update_attendance: function (type) { + var self = this; + this._rpc({ + model: 'hr.employee', + method: 'attendance_manual', + args: [[self.employee.id], 'hr_attendance.hr_attendance_action_my_attendances', false, type], + }) + .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', MyTypedAttendances); + +return MyTypedAttendances; + +}); \ No newline at end of file diff --git a/hr_attendance_work_entry/static/src/scss/hr_attendances.scss b/hr_attendance_work_entry/static/src/scss/hr_attendances.scss new file mode 100644 index 00000000..308412de --- /dev/null +++ b/hr_attendance_work_entry/static/src/scss/hr_attendances.scss @@ -0,0 +1,31 @@ +.o_hr_attendance_sign_in_out_icon { + cursor: pointer; + border-radius: .1em; + box-shadow: inset 0 -3px 0 fade-out(black, 0.7); + + &.btn-secondary:hover { + color: $o-brand-primary; + } +} + +#oe_hr_attendance_status { + color: $o-brand-secondary; + + &.oe_hr_attendance_status_blue { + color: theme-color('info'); + } + + &.oe_hr_attendance_status_orange { + color: theme-color('warning'); + } +} + +.o_hr_attendance_kiosk_mode p.o_hr_attendance_continue { + margin-bottom: 0; + text-align: center; + font-weight: bold; +} + +.o_hr_attendance_pin_pad_button_work { + font-size: 0.9em; +} diff --git a/hr_attendance_work_entry/static/src/xml/hr_attendance.xml b/hr_attendance_work_entry/static/src/xml/hr_attendance.xml new file mode 100644 index 00000000..e356c106 --- /dev/null +++ b/hr_attendance_work_entry/static/src/xml/hr_attendance.xml @@ -0,0 +1,134 @@ + + + + + +
+ + + +
+ +
+

+

Welcome! + Want to check out?

+

Today's work hours:

+
+
+ +
+ + + +

+ Or, continue working as: +

+ + + + + + + +
+ +
+
+
+ + Warning : Your user should be linked to an employee to use attendance. Please contact your administrator. + +
+ +
+
+ + + +
+ + +
+ Go back + +
+ +
+ +
+

+

Welcome! Want to check out?

+

Today's work hours:

+ +
+ + + +

+ Or, continue working as: +

+ + + + + + + +
+
+ +

Please enter your PIN to check outcheck in

+
+
+
+
+
+
+ +
+ +
+ +
+ Sign Out +
+
+ +
+ +
+ + + +
+
+
+ + + +