[IMP] hr_(holidays)_attendance : Revamp Attendance V17
Part-of: odoo/odoo#135439
This commit is contained in:
@@ -3,4 +3,3 @@
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import report
|
||||
|
||||
@@ -22,10 +22,10 @@ actions(Check in/Check out) performed by them.
|
||||
'security/ir.model.access.csv',
|
||||
'views/hr_attendance_view.xml',
|
||||
'views/hr_attendance_overtime_view.xml',
|
||||
'report/hr_attendance_report_views.xml',
|
||||
'views/hr_department_view.xml',
|
||||
'views/hr_employee_view.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/hr_attendance_kiosk_templates.xml'
|
||||
],
|
||||
'demo': [
|
||||
'data/hr_attendance_demo.xml'
|
||||
@@ -37,8 +37,51 @@ actions(Check in/Check out) performed by them.
|
||||
'hr_attendance/static/src/**/*',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'hr_attendance/static/tests/client_action_tests.js',
|
||||
'hr_attendance/static/tests/hr_attendance_mock_server.js',
|
||||
],
|
||||
'web.qunit_mobile_suite_tests': [
|
||||
'hr_attendance/static/tests/hr_attendance_mock_server.js',
|
||||
],
|
||||
'hr_attendance.assets_public_attendance': [
|
||||
# Front-end libraries
|
||||
('include', 'web._assets_helpers'),
|
||||
('include', 'web._assets_frontend_helpers'),
|
||||
'web/static/src/scss/pre_variables.scss',
|
||||
'web/static/lib/bootstrap/scss/_variables.scss',
|
||||
('include', 'web._assets_bootstrap_frontend'),
|
||||
('include', 'web._assets_bootstrap_backend'),
|
||||
'/web/static/lib/odoo_ui_icons/*',
|
||||
'/web/static/lib/bootstrap/scss/_functions.scss',
|
||||
'/web/static/lib/bootstrap/scss/_mixins.scss',
|
||||
'/web/static/lib/bootstrap/scss/utilities/_api.scss',
|
||||
'web/static/src/libs/fontawesome/css/font-awesome.css',
|
||||
('include', 'web._assets_core'),
|
||||
|
||||
# Public Kiosk app and its components
|
||||
"hr_attendance/static/src/public_kiosk/**/*",
|
||||
"hr_attendance/static/src/hr_attendance.scss",
|
||||
'hr_attendance/static/src/components/**/*',
|
||||
"web/static/src/views/fields/formatters.js",
|
||||
|
||||
# Barcode reader utils
|
||||
"web/static/src/webclient/barcode/barcode_scanner.js",
|
||||
"web/static/src/webclient/barcode/barcode_scanner.xml",
|
||||
"web/static/src/webclient/barcode/barcode_scanner.scss",
|
||||
"web/static/src/webclient/barcode/crop_overlay.js",
|
||||
"web/static/src/webclient/webclient_layout.scss",
|
||||
"web/static/src/webclient/barcode/crop_overlay.xml",
|
||||
"web/static/src/webclient/barcode/crop_overlay.scss",
|
||||
"web/static/src/webclient/barcode/ZXingBarcodeDetector.js",
|
||||
"barcodes/static/src/components/barcode_scanner.js",
|
||||
"barcodes/static/src/components/barcode_scanner.xml",
|
||||
"barcodes/static/src/components/barcode_scanner.scss",
|
||||
"barcodes/static/src/barcode_service.js",
|
||||
|
||||
# Kanban view mock
|
||||
"web/static/src/views/kanban/kanban_controller.scss",
|
||||
"web/static/src/search/search_panel/search_panel.scss",
|
||||
"web/static/src/search/control_panel/control_panel.scss",
|
||||
]
|
||||
},
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
@@ -1,12 +1,151 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import http
|
||||
from odoo import http, _
|
||||
from odoo.http import request
|
||||
|
||||
import datetime
|
||||
|
||||
class HrAttendance(http.Controller):
|
||||
@staticmethod
|
||||
def _get_company(token):
|
||||
company = request.env['res.company'].sudo().search([('attendance_kiosk_key', '=', token)])
|
||||
return company
|
||||
|
||||
@staticmethod
|
||||
def _get_employee_info_response(employee):
|
||||
response = {}
|
||||
if employee:
|
||||
response = {
|
||||
'id': employee.id,
|
||||
'employee_name': employee.name,
|
||||
'employee_avatar': employee.image_1920,
|
||||
'hours_today': employee.hours_today,
|
||||
'total_overtime': employee.total_overtime,
|
||||
'last_attendance_worked_hours': employee.last_attendance_worked_hours,
|
||||
'last_check_in': employee.last_check_in,
|
||||
'attendance_state': employee.attendance_state,
|
||||
'hours_previously_today': employee.hours_previously_today,
|
||||
'kiosk_delay': employee.company_id.attendance_kiosk_delay * 1000,
|
||||
'attendance': {'check_in': employee.last_attendance_id.check_in,
|
||||
'check_out': employee.last_attendance_id.check_out},
|
||||
'overtime_today': request.env['hr.attendance.overtime'].sudo().search([
|
||||
('employee_id', '=', employee.id), ('date', '=', datetime.date.today()),
|
||||
('adjustment', '=', False)]).duration or 0,
|
||||
'use_pin': employee.company_id.attendance_kiosk_use_pin,
|
||||
'display_systray': employee.company_id.attendance_from_systray,
|
||||
'display_overtime': employee.company_id.hr_attendance_display_overtime
|
||||
}
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _get_geoip_response(mode, latitude=False, longitude=False):
|
||||
return {
|
||||
'city': request.geoip.city.name or _('Unknown'),
|
||||
'country_name': request.geoip.country.name or request.geoip.continent.name or _('Unknown'),
|
||||
'latitude': latitude or request.geoip.location.latitude or False,
|
||||
'longitude': longitude or request.geoip.location.longitude or False,
|
||||
'ip_address': request.geoip.ip,
|
||||
'browser': request.httprequest.user_agent.browser,
|
||||
'mode': mode
|
||||
}
|
||||
|
||||
@http.route('/hr_attendance/kiosk_mode_menu', auth='user', type='http')
|
||||
def kiosk_menu_item_action(self):
|
||||
if request.env.user.user_has_groups("hr_attendance.group_hr_attendance_manager"):
|
||||
# Auto log out will prevent users from forgetting to log out of their session
|
||||
# before leaving the kiosk mode open to the public. This is a prevention security
|
||||
# measure.
|
||||
request.session.logout(keep_db=True)
|
||||
return request.redirect(request.env.user.company_id.attendance_kiosk_url)
|
||||
else:
|
||||
return request.not_found()
|
||||
|
||||
@http.route('/hr_attendance/kiosk_keepalive', auth='user', type='json')
|
||||
def kiosk_keepalive(self):
|
||||
request.session.touch()
|
||||
return {}
|
||||
|
||||
@http.route(["/hr_attendance/<token>"], type='http', auth='public', website=True, sitemap=True)
|
||||
def open_kiosk_mode(self, token):
|
||||
company = self._get_company(token)
|
||||
if not company:
|
||||
return request.not_found()
|
||||
else:
|
||||
employee_list = [{"id": e["id"],
|
||||
"name": e["name"],
|
||||
"avatar": e["avatar_1024"].decode(),
|
||||
"job": e["job_id"][1] if e["job_id"] else False,
|
||||
"department": {"id": e["department_id"][0] if e["department_id"] else False,
|
||||
"name": e["department_id"][1] if e["department_id"] else False
|
||||
}
|
||||
} for e in request.env['hr.employee'].sudo().search_read(domain=[('company_id', '=', company.id)],
|
||||
fields=["id",
|
||||
"name",
|
||||
"avatar_1024",
|
||||
"job_id",
|
||||
"department_id"])]
|
||||
departement_list = [{'id': dep["id"],
|
||||
'name': dep["name"],
|
||||
'count': dep["total_employee"]
|
||||
} for dep in request.env['hr.department'].sudo().search_read(domain=[('company_id', '=', company.id)],
|
||||
fields=["id",
|
||||
"name",
|
||||
"total_employee"])]
|
||||
request.session.logout(keep_db=True)
|
||||
return request.render(
|
||||
'hr_attendance.public_kiosk_mode',
|
||||
{
|
||||
'kiosk_backend_info': {
|
||||
'token': token,
|
||||
'company_id': company.id,
|
||||
'company_name': company.name,
|
||||
'employees': employee_list,
|
||||
'departments': departement_list,
|
||||
'kiosk_mode': company.attendance_kiosk_mode,
|
||||
'barcode_source': company.attendance_barcode_source
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@http.route('/hr_attendance/attendance_employee_data', type="json", auth="public")
|
||||
def employee_attendance_data(self, token, employee_id):
|
||||
company = self._get_company(token)
|
||||
if company:
|
||||
employee = request.env['hr.employee'].sudo().browse(employee_id)
|
||||
if employee.company_id == company:
|
||||
return self._get_employee_info_response(employee)
|
||||
return {}
|
||||
|
||||
@http.route('/hr_attendance/attendance_barcode_scanned', type="json", auth="public")
|
||||
def scan_barcode(self, token, barcode):
|
||||
company = self._get_company(token)
|
||||
if company:
|
||||
employee = request.env['hr.employee'].sudo().search([('barcode', '=', barcode), ('company_id', '=', company.id)], limit=1)
|
||||
if employee:
|
||||
employee._attendance_action_change(self._get_geoip_response('kiosk'))
|
||||
return self._get_employee_info_response(employee)
|
||||
return {}
|
||||
|
||||
@http.route('/hr_attendance/manual_selection', type="json", auth="public")
|
||||
def manual_selection(self, token, employee_id, pin_code):
|
||||
company = self._get_company(token)
|
||||
if company:
|
||||
employee = request.env['hr.employee'].sudo().browse(employee_id)
|
||||
if employee.company_id == company and ((not company.attendance_kiosk_use_pin) or (employee.pin == pin_code)):
|
||||
employee.sudo()._attendance_action_change(self._get_geoip_response('kiosk'))
|
||||
return self._get_employee_info_response(employee)
|
||||
return {}
|
||||
|
||||
@http.route('/hr_attendance/systray_check_in_out', type="json", auth="user")
|
||||
def systray_attendance(self, latitude=False, longitude=False):
|
||||
employee = request.env.user.employee_id
|
||||
geo_ip_response = self._get_geoip_response(mode='systray',
|
||||
latitude=latitude,
|
||||
longitude=longitude)
|
||||
employee._attendance_action_change(geo_ip_response)
|
||||
return self._get_employee_info_response(employee)
|
||||
|
||||
@http.route('/hr_attendance/attendance_user_data', type="json", auth="user")
|
||||
def user_attendance_data(self):
|
||||
employee = request.env.user.employee_id
|
||||
return self._get_employee_info_response(employee)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<odoo>
|
||||
<data noupdate="True">
|
||||
<record id="base.user_demo" model="res.users">
|
||||
<field name="groups_id" eval="[(4, ref('hr_attendance.group_hr_attendance_user'))]"/>
|
||||
<field name="groups_id" eval="[(4, ref('hr_attendance.group_hr_attendance_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="hr.employee_al" model="hr.employee">
|
||||
@@ -11,6 +11,7 @@
|
||||
</record>
|
||||
|
||||
<record id="hr.employee_admin" model="hr.employee">
|
||||
<field name="pin">0000</field>
|
||||
<field name="barcode">456</field>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -6,6 +6,5 @@ from . import hr_attendance_overtime
|
||||
from . import hr_employee_base
|
||||
from . import hr_employee
|
||||
from . import hr_employee_public
|
||||
from . import ir_ui_menu
|
||||
from . import res_company
|
||||
from . import res_users
|
||||
|
||||
@@ -14,12 +14,17 @@ from odoo.tools import format_datetime
|
||||
from odoo.osv.expression import AND, OR
|
||||
from odoo.tools.float_utils import float_is_zero
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.tools import format_duration
|
||||
|
||||
def get_google_maps_url(latitude, longitude):
|
||||
return "https://maps.google.com?q=%s,%s" % (latitude, longitude)
|
||||
|
||||
|
||||
class HrAttendance(models.Model):
|
||||
_name = "hr.attendance"
|
||||
_description = "Attendance"
|
||||
_order = "check_in desc"
|
||||
_inherit = "mail.thread"
|
||||
|
||||
def _default_employee(self):
|
||||
return self.env.user.employee_id
|
||||
@@ -27,25 +32,101 @@ class HrAttendance(models.Model):
|
||||
employee_id = fields.Many2one('hr.employee', string="Employee", default=_default_employee, required=True, ondelete='cascade', index=True)
|
||||
department_id = fields.Many2one('hr.department', string="Department", related="employee_id.department_id",
|
||||
readonly=True)
|
||||
check_in = fields.Datetime(string="Check In", default=fields.Datetime.now, required=True)
|
||||
check_out = fields.Datetime(string="Check Out")
|
||||
check_in = fields.Datetime(string="Check In", default=fields.Datetime.now, required=True, tracking=True)
|
||||
check_out = fields.Datetime(string="Check Out", tracking=True)
|
||||
worked_hours = fields.Float(string='Worked Hours', compute='_compute_worked_hours', store=True, readonly=True)
|
||||
color = fields.Integer(compute='_compute_color')
|
||||
overtime_hours = fields.Float(string="Over Time", compute='_compute_overtime_hours', store=True)
|
||||
in_latitude = fields.Float(string="Latitude", digits=(10, 7), readonly=True)
|
||||
in_longitude = fields.Float(string="Longitude", digits=(10, 7), readonly=True)
|
||||
in_country_name = fields.Char(string="Country", help="Based on IP Address", readonly=True)
|
||||
in_city = fields.Char(string="City", readonly=True)
|
||||
in_ip_address = fields.Char(string="IP Address", readonly=True)
|
||||
in_browser = fields.Char(string="Browser", readonly=True)
|
||||
in_mode = fields.Selection(string="Mode",
|
||||
selection=[('kiosk', "Kiosk"),
|
||||
('systray', "Systray"),
|
||||
('manual', "Manual")],
|
||||
readonly=True,
|
||||
default='manual')
|
||||
out_latitude = fields.Float(digits=(10, 7), readonly=True)
|
||||
out_longitude = fields.Float(digits=(10, 7), readonly=True)
|
||||
out_country_name = fields.Char(help="Based on IP Address", readonly=True)
|
||||
out_city = fields.Char(readonly=True)
|
||||
out_ip_address = fields.Char(readonly=True)
|
||||
out_browser = fields.Char(readonly=True)
|
||||
out_mode = fields.Selection(selection=[('kiosk', "Kiosk"),
|
||||
('systray', "Systray"),
|
||||
('manual', "Manual")],
|
||||
readonly=True,
|
||||
default='manual')
|
||||
|
||||
def _compute_color(self):
|
||||
for attendance in self:
|
||||
if attendance.check_out:
|
||||
attendance.color = 1 if attendance.worked_hours > 16 else 0
|
||||
else:
|
||||
attendance.color = 1 if attendance.check_in < (datetime.today() - timedelta(days=1)) else 10
|
||||
|
||||
@api.depends('worked_hours')
|
||||
def _compute_overtime_hours(self):
|
||||
self.env['hr.attendance'].flush_model(['worked_hours'])
|
||||
self.env['hr.attendance.overtime'].flush_model(['duration'])
|
||||
self.env.cr.execute('''
|
||||
SELECT att.id as att_id,
|
||||
att.worked_hours as att_wh,
|
||||
ot.id as ot_id,
|
||||
ot.duration as ot_d,
|
||||
ot.date as od,
|
||||
att.check_in as ad
|
||||
FROM hr_attendance att
|
||||
INNER JOIN hr_attendance_overtime ot
|
||||
ON date_trunc('day',att.check_in) = date_trunc('day', ot.date)
|
||||
AND date_trunc('day',att.check_out) = date_trunc('day', ot.date)
|
||||
AND att.employee_id IN %s
|
||||
AND att.employee_id = ot.employee_id
|
||||
ORDER BY att.check_in DESC
|
||||
''', (tuple(self.employee_id.ids),))
|
||||
a = self.env.cr.dictfetchall()
|
||||
grouped_dict = dict()
|
||||
for row in a:
|
||||
if row['ot_id'] and row['att_wh']:
|
||||
if row['ot_id'] not in grouped_dict:
|
||||
grouped_dict[row['ot_id']] = {'attendances': [(row['att_id'], row['att_wh'])], 'overtime_duration': row['ot_d']}
|
||||
else:
|
||||
grouped_dict[row['ot_id']]['attendances'].append((row['att_id'], row['att_wh']))
|
||||
|
||||
att_progress_values = dict()
|
||||
for ot in grouped_dict:
|
||||
ot_bucket = grouped_dict[ot]['overtime_duration']
|
||||
for att in grouped_dict[ot]['attendances']:
|
||||
if ot_bucket > 0:
|
||||
sub_time = att[1] - ot_bucket
|
||||
if sub_time < 0:
|
||||
att_progress_values[att[0]] = 0
|
||||
ot_bucket -= att[1]
|
||||
else:
|
||||
att_progress_values[att[0]] = float(((att[1] - ot_bucket) / att[1])*100)
|
||||
ot_bucket = 0
|
||||
else:
|
||||
att_progress_values[att[0]] = 100
|
||||
for attendance in self:
|
||||
attendance.overtime_hours = attendance.worked_hours * ((100 - att_progress_values.get(attendance.id, 100))/100)
|
||||
|
||||
@api.depends('employee_id', 'check_in', 'check_out')
|
||||
def _compute_display_name(self):
|
||||
for attendance in self:
|
||||
if not attendance.check_out:
|
||||
attendance.display_name = _(
|
||||
"%s from %s",
|
||||
attendance.employee_id.name,
|
||||
format_datetime(self.env, attendance.check_in, dt_format=False)
|
||||
"From %s",
|
||||
format_datetime(self.env, attendance.check_in, dt_format="HH:mm"),
|
||||
)
|
||||
else:
|
||||
attendance.display_name = _(
|
||||
"%s from %s to %s",
|
||||
attendance.employee_id.name,
|
||||
format_datetime(self.env, attendance.check_in, dt_format=False),
|
||||
format_datetime(self.env, attendance.check_out, dt_format=False),
|
||||
"%s : (%s-%s)",
|
||||
format_duration(attendance.worked_hours),
|
||||
format_datetime(self.env, attendance.check_in, dt_format="HH:mm"),
|
||||
format_datetime(self.env, attendance.check_out, dt_format="HH:mm"),
|
||||
)
|
||||
|
||||
def _get_employee_calendar(self):
|
||||
@@ -156,7 +237,7 @@ class HrAttendance(models.Model):
|
||||
|
||||
overtime_to_unlink = self.env['hr.attendance.overtime']
|
||||
overtime_vals_list = []
|
||||
|
||||
affected_employees = self.env['hr.employee']
|
||||
for emp, attendance_dates in employee_attendance_dates.items():
|
||||
# get_attendances_dates returns the date translated from the local timezone without tzinfo,
|
||||
# and contains all the date which we need to check for overtime
|
||||
@@ -295,10 +376,16 @@ class HrAttendance(models.Model):
|
||||
'duration': overtime_duration,
|
||||
'duration_real': overtime_duration
|
||||
})
|
||||
affected_employees |= overtime.employee_id
|
||||
elif overtime:
|
||||
overtime_to_unlink |= overtime
|
||||
self.env['hr.attendance.overtime'].sudo().create(overtime_vals_list)
|
||||
created_overtimes = self.env['hr.attendance.overtime'].sudo().create(overtime_vals_list)
|
||||
employees_worked_hours_to_compute = (affected_employees.ids +
|
||||
created_overtimes.employee_id.ids +
|
||||
overtime_to_unlink.employee_id.ids)
|
||||
overtime_to_unlink.sudo().unlink()
|
||||
self.env.add_to_compute(self._fields['overtime_hours'],
|
||||
self.search([('employee_id', 'in', employees_worked_hours_to_compute)]))
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
@@ -309,7 +396,7 @@ class HrAttendance(models.Model):
|
||||
def write(self, vals):
|
||||
if vals.get('employee_id') and \
|
||||
vals['employee_id'] not in self.env.user.employee_ids.ids and \
|
||||
not self.env.user.has_group('hr_attendance.group_hr_attendance_user'):
|
||||
not self.env.user.has_group('hr_attendance.group_hr_attendance_officer'):
|
||||
raise AccessError(_("Do not have access, user cannot edit the attendances that are not his own."))
|
||||
attendances_dates = self._get_attendances_dates()
|
||||
result = super(HrAttendance, self).write(vals)
|
||||
@@ -330,3 +417,19 @@ class HrAttendance(models.Model):
|
||||
@api.returns('self', lambda value: value.id)
|
||||
def copy(self, default=None):
|
||||
raise exceptions.UserError(_('You cannot duplicate an attendance.'))
|
||||
|
||||
def action_in_attendance_maps(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': get_google_maps_url(self.in_latitude, self.in_longitude),
|
||||
'target': 'new'
|
||||
}
|
||||
|
||||
def action_out_attendance_maps(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': get_google_maps_url(self.out_latitude, self.out_longitude),
|
||||
'target': 'new'
|
||||
}
|
||||
|
||||
@@ -11,33 +11,70 @@ from odoo.tools import float_round
|
||||
class HrEmployee(models.Model):
|
||||
_inherit = "hr.employee"
|
||||
|
||||
attendance_manager_id = fields.Many2one(
|
||||
'res.users', store=True, readonly=False,
|
||||
domain="[('share', '=', False), ('company_ids', 'in', company_id)]",
|
||||
groups="hr_attendance.group_hr_attendance_manager",
|
||||
help="The user set in Attendance will access the attendance of the employee through the dedicated app and will be able to edit them.")
|
||||
attendance_ids = fields.One2many(
|
||||
'hr.attendance', 'employee_id', groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
|
||||
'hr.attendance', 'employee_id', groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
|
||||
last_attendance_id = fields.Many2one(
|
||||
'hr.attendance', compute='_compute_last_attendance_id', store=True,
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance,hr.group_hr_user")
|
||||
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
|
||||
last_check_in = fields.Datetime(
|
||||
related='last_attendance_id.check_in', store=True,
|
||||
groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
|
||||
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user", tracking=False)
|
||||
last_check_out = fields.Datetime(
|
||||
related='last_attendance_id.check_out', store=True,
|
||||
groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
|
||||
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user", tracking=False)
|
||||
attendance_state = fields.Selection(
|
||||
string="Attendance Status", compute='_compute_attendance_state',
|
||||
selection=[('checked_out', "Checked out"), ('checked_in', "Checked in")],
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance,hr.group_hr_user")
|
||||
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
|
||||
hours_last_month = fields.Float(
|
||||
compute='_compute_hours_last_month', groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
|
||||
compute='_compute_hours_last_month', groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
|
||||
hours_today = fields.Float(
|
||||
compute='_compute_hours_today',
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance,hr.group_hr_user")
|
||||
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
|
||||
hours_previously_today = fields.Float(
|
||||
compute='_compute_hours_today',
|
||||
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
|
||||
last_attendance_worked_hours = fields.Float(
|
||||
compute='_compute_hours_today',
|
||||
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
|
||||
hours_last_month_display = fields.Char(
|
||||
compute='_compute_hours_last_month', groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
|
||||
compute='_compute_hours_last_month')
|
||||
overtime_ids = fields.One2many(
|
||||
'hr.attendance.overtime', 'employee_id', groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
|
||||
'hr.attendance.overtime', 'employee_id', groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
|
||||
total_overtime = fields.Float(
|
||||
compute='_compute_total_overtime', compute_sudo=True,
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance,hr.group_hr_user")
|
||||
compute='_compute_total_overtime', compute_sudo=True)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
officer_group = self.env.ref('hr_attendance.group_hr_attendance_officer', raise_if_not_found=False)
|
||||
group_updates = []
|
||||
for vals in vals_list:
|
||||
if officer_group and vals.get('attendance_manager_id'):
|
||||
group_updates.append((4, vals['attendance_manager_id']))
|
||||
if group_updates:
|
||||
officer_group.sudo().write({'users': group_updates})
|
||||
return super().create(vals_list)
|
||||
|
||||
def write(self, values):
|
||||
old_officers = self.env['res.users']
|
||||
if 'attendance_manager_id' in values:
|
||||
old_officers = self.attendance_manager_id
|
||||
# Officer was added
|
||||
if values['attendance_manager_id']:
|
||||
officer = self.env['res.users'].browse(values['attendance_manager_id'])
|
||||
officers_group = self.env.ref('hr_attendance.group_hr_attendance_officer', raise_if_not_found=False)
|
||||
if officers_group and not officer.has_group('hr_attendance.group_hr_attendance_officer'):
|
||||
officer.sudo().write({'groups_id': [(4, officers_group.id)]})
|
||||
|
||||
res = super(HrEmployee, self).write(values)
|
||||
old_officers.sudo()._clean_attendance_officers()
|
||||
|
||||
return res
|
||||
|
||||
@api.depends('overtime_ids.duration', 'attendance_ids')
|
||||
def _compute_total_overtime(self):
|
||||
@@ -48,30 +85,26 @@ class HrEmployee(models.Model):
|
||||
employee.total_overtime = 0
|
||||
|
||||
def _compute_hours_last_month(self):
|
||||
"""
|
||||
Compute hours in the current month, if we are the 15th of october, will compute hours from 1 oct to 15 oct
|
||||
"""
|
||||
now = fields.Datetime.now()
|
||||
now_utc = pytz.utc.localize(now)
|
||||
for employee in self:
|
||||
tz = pytz.timezone(employee.tz or 'UTC')
|
||||
now_tz = now_utc.astimezone(tz)
|
||||
start_tz = now_tz + relativedelta(months=-1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
start_tz = now_tz.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
start_naive = start_tz.astimezone(pytz.utc).replace(tzinfo=None)
|
||||
end_tz = now_tz + relativedelta(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end_tz = now_tz
|
||||
end_naive = end_tz.astimezone(pytz.utc).replace(tzinfo=None)
|
||||
|
||||
attendances = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', employee.id),
|
||||
'&',
|
||||
('check_in', '<=', end_naive),
|
||||
('check_out', '>=', start_naive),
|
||||
('check_in', '>=', start_naive),
|
||||
('check_out', '<=', end_naive),
|
||||
])
|
||||
|
||||
hours = 0
|
||||
for attendance in attendances:
|
||||
check_in = max(attendance.check_in, start_naive)
|
||||
check_out = min(attendance.check_out, end_naive)
|
||||
hours += (check_out - check_in).total_seconds() / 3600.0
|
||||
|
||||
employee.hours_last_month = round(hours, 2)
|
||||
employee.hours_last_month = round(sum([att.worked_hours for att in attendances]), 2)
|
||||
employee.hours_last_month_display = "%g" % employee.hours_last_month
|
||||
|
||||
def _compute_hours_today(self):
|
||||
@@ -88,12 +121,18 @@ class HrEmployee(models.Model):
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '<=', now),
|
||||
'|', ('check_out', '>=', start_naive), ('check_out', '=', False),
|
||||
])
|
||||
|
||||
], order='check_in asc')
|
||||
hours_previously_today = 0
|
||||
worked_hours = 0
|
||||
attendance_worked_hours = 0
|
||||
for attendance in attendances:
|
||||
delta = (attendance.check_out or now) - max(attendance.check_in, start_naive)
|
||||
worked_hours += delta.total_seconds() / 3600.0
|
||||
attendance_worked_hours = delta.total_seconds() / 3600.0
|
||||
worked_hours += attendance_worked_hours
|
||||
hours_previously_today += attendance_worked_hours
|
||||
employee.last_attendance_worked_hours = attendance_worked_hours
|
||||
hours_previously_today -= attendance_worked_hours
|
||||
employee.hours_previously_today = hours_previously_today
|
||||
employee.hours_today = worked_hours
|
||||
|
||||
@api.depends('attendance_ids')
|
||||
@@ -101,7 +140,7 @@ class HrEmployee(models.Model):
|
||||
for employee in self:
|
||||
employee.last_attendance_id = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', employee.id),
|
||||
], limit=1)
|
||||
], order="check_in desc", limit=1)
|
||||
|
||||
@api.depends('last_attendance_id.check_in', 'last_attendance_id.check_out', 'last_attendance_id')
|
||||
def _compute_attendance_state(self):
|
||||
@@ -109,55 +148,7 @@ class HrEmployee(models.Model):
|
||||
att = employee.last_attendance_id.sudo()
|
||||
employee.attendance_state = att and not att.check_out and 'checked_in' or 'checked_out'
|
||||
|
||||
@api.model
|
||||
def attendance_scan(self, barcode):
|
||||
""" Receive a barcode scanned from the Kiosk Mode and change the attendances of corresponding employee.
|
||||
Returns either an action or a warning.
|
||||
"""
|
||||
employee = self.sudo().search([('barcode', '=', barcode)], limit=1)
|
||||
if employee:
|
||||
return employee._attendance_action('hr_attendance.hr_attendance_action_kiosk_mode')
|
||||
return {'warning': _("No employee corresponding to Badge ID '%(barcode)s.'") % {'barcode': barcode}}
|
||||
|
||||
def attendance_manual(self, next_action, entered_pin=None):
|
||||
self.ensure_one()
|
||||
attendance_user_and_no_pin = self.user_has_groups(
|
||||
'hr_attendance.group_hr_attendance_user,'
|
||||
'!hr_attendance.group_hr_attendance_use_pin')
|
||||
can_check_without_pin = attendance_user_and_no_pin or (self.user_id == self.env.user and entered_pin is None)
|
||||
if can_check_without_pin or entered_pin is not None and entered_pin == self.sudo().pin:
|
||||
return self._attendance_action(next_action)
|
||||
if not self.user_has_groups('hr_attendance.group_hr_attendance_user'):
|
||||
return {'warning': _('To activate Kiosk mode without pin code, you must have access right as an Officer or above in the Attendance app. Please contact your administrator.')}
|
||||
return {'warning': _('Wrong PIN')}
|
||||
|
||||
def _attendance_action(self, next_action):
|
||||
""" Changes the attendance of the employee.
|
||||
Returns an action to the check in/out message,
|
||||
next_action defines which menu the check in/out message should return to. ("My Attendances" or "Kiosk Mode")
|
||||
"""
|
||||
self.ensure_one()
|
||||
employee = self.sudo()
|
||||
action_message = self.env["ir.actions.actions"]._for_xml_id("hr_attendance.hr_attendance_action_greeting_message")
|
||||
action_message['previous_attendance_change_date'] = employee.last_attendance_id and (employee.last_attendance_id.check_out or employee.last_attendance_id.check_in) or False
|
||||
action_message['employee_name'] = employee.name
|
||||
action_message['barcode'] = employee.barcode
|
||||
action_message['next_action'] = next_action
|
||||
action_message['hours_today'] = employee.hours_today
|
||||
action_message['kiosk_delay'] = employee.company_id.attendance_kiosk_delay * 1000
|
||||
|
||||
if employee.user_id:
|
||||
modified_attendance = employee.with_user(employee.user_id).sudo()._attendance_action_change()
|
||||
else:
|
||||
modified_attendance = employee._attendance_action_change()
|
||||
action_message['attendance'] = modified_attendance.read()[0]
|
||||
action_message['total_overtime'] = employee.total_overtime
|
||||
# Overtime have an unique constraint on the day, no need for limit=1
|
||||
action_message['overtime_today'] = self.env['hr.attendance.overtime'].sudo().search([
|
||||
('employee_id', '=', employee.id), ('date', '=', fields.Date.context_today(self)), ('adjustment', '=', False)]).duration or 0
|
||||
return {'action': action_message}
|
||||
|
||||
def _attendance_action_change(self):
|
||||
def _attendance_action_change(self, geo_information=None):
|
||||
""" Check In/Check Out action
|
||||
Check In: create a new attendance record
|
||||
Check Out: modify check_out field of appropriate attendance record
|
||||
@@ -166,15 +157,57 @@ class HrEmployee(models.Model):
|
||||
action_date = fields.Datetime.now()
|
||||
|
||||
if self.attendance_state != 'checked_in':
|
||||
vals = {
|
||||
'employee_id': self.id,
|
||||
'check_in': action_date,
|
||||
}
|
||||
if geo_information:
|
||||
vals = {
|
||||
'employee_id': self.id,
|
||||
'check_in': action_date,
|
||||
**{'in_%s' % key: geo_information[key] for key in geo_information}
|
||||
}
|
||||
else:
|
||||
vals = {
|
||||
'employee_id': self.id,
|
||||
'check_in': action_date,
|
||||
}
|
||||
return self.env['hr.attendance'].create(vals)
|
||||
attendance = self.env['hr.attendance'].search([('employee_id', '=', self.id), ('check_out', '=', False)], limit=1)
|
||||
if attendance:
|
||||
attendance.check_out = action_date
|
||||
if geo_information:
|
||||
attendance.write({
|
||||
'check_out': action_date,
|
||||
**{'out_%s' % key: geo_information[key] for key in geo_information}
|
||||
})
|
||||
else:
|
||||
attendance.write({
|
||||
'check_out': action_date
|
||||
})
|
||||
else:
|
||||
raise exceptions.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
|
||||
|
||||
def action_open_last_month_attendances(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": _("Attendances This Month"),
|
||||
"res_model": "hr.attendance",
|
||||
"views": [[self.env.ref('hr_attendance.hr_attendance_employee_simple_tree_view').id, "tree"]],
|
||||
"context": {
|
||||
"create": 0
|
||||
},
|
||||
"domain": [('employee_id', '=', self.id),
|
||||
('check_in', ">=", fields.datetime.today().replace(day=1, hour=0, minute=0))]
|
||||
}
|
||||
|
||||
def action_open_last_month_overtime(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": _("Overtime"),
|
||||
"res_model": "hr.attendance.overtime",
|
||||
"views": [[False, "tree"]],
|
||||
"context": {
|
||||
"create": 0
|
||||
},
|
||||
"domain": [('employee_id', '=', self.id)]
|
||||
}
|
||||
|
||||
@@ -8,22 +8,16 @@ class HrEmployeePublic(models.Model):
|
||||
|
||||
# These are required for manual attendance
|
||||
attendance_state = fields.Selection(related='employee_id.attendance_state', readonly=True,
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance")
|
||||
groups="hr_attendance.group_hr_attendance_officer")
|
||||
hours_today = fields.Float(related='employee_id.hours_today', readonly=True,
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance")
|
||||
groups="hr_attendance.group_hr_attendance_officer")
|
||||
last_attendance_id = fields.Many2one(related='employee_id.last_attendance_id', readonly=True,
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance")
|
||||
groups="hr_attendance.group_hr_attendance_officer")
|
||||
total_overtime = fields.Float(related='employee_id.total_overtime', readonly=True,
|
||||
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance")
|
||||
|
||||
def action_employee_kiosk_confirm(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'name': 'Confirm',
|
||||
'tag': 'hr_attendance_kiosk_confirm',
|
||||
'employee_id': self.id,
|
||||
'employee_name': self.name,
|
||||
'employee_state': self.attendance_state,
|
||||
'employee_hours_today': self.hours_today,
|
||||
}
|
||||
groups="hr_attendance.group_hr_attendance_officer")
|
||||
attendance_manager_id = fields.Many2one(related='employee_id.attendance_manager_id',
|
||||
groups="hr_attendance.group_hr_attendance_officer")
|
||||
last_check_in = fields.Datetime(related='employee_id.last_check_in',
|
||||
groups="hr_attendance.group_hr_attendance_officer")
|
||||
last_check_out = fields.Datetime(related='employee_id.last_check_out',
|
||||
groups="hr_attendance.group_hr_attendance_officer")
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class IrUiMenu(models.Model):
|
||||
_inherit = 'ir.ui.menu'
|
||||
|
||||
def _load_menus_blacklist(self):
|
||||
res = super()._load_menus_blacklist()
|
||||
if self.env.user.has_group('hr_attendance.group_hr_attendance_user'):
|
||||
res.append(self.env.ref('hr_attendance.menu_hr_attendance_attendances_overview').id)
|
||||
return res
|
||||
@@ -1,8 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo import fields, models, api
|
||||
from odoo.osv.expression import OR
|
||||
import uuid
|
||||
from werkzeug.urls import url_join
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
@@ -12,7 +14,7 @@ class ResCompany(models.Model):
|
||||
overtime_start_date = fields.Date(string="Extra Hours Starting Date")
|
||||
overtime_company_threshold = fields.Integer(string="Tolerance Time In Favor Of Company", default=0)
|
||||
overtime_employee_threshold = fields.Integer(string="Tolerance Time In Favor Of Employee", default=0)
|
||||
|
||||
hr_attendance_display_overtime = fields.Boolean(string="Display Extra Hours")
|
||||
attendance_kiosk_mode = fields.Selection([
|
||||
('barcode', 'Barcode / RFID'),
|
||||
('barcode_manual', 'Barcode / RFID and Manual Selection'),
|
||||
@@ -24,6 +26,15 @@ class ResCompany(models.Model):
|
||||
('back', 'Back Camera'),
|
||||
], string='Barcode Source', default='front')
|
||||
attendance_kiosk_delay = fields.Integer(default=10)
|
||||
attendance_kiosk_key = fields.Char(default=lambda s: uuid.uuid4().hex, copy=False, groups='hr_attendance.group_hr_attendance_manager')
|
||||
attendance_kiosk_url = fields.Char(compute="_compute_attendance_kiosk_url")
|
||||
attendance_kiosk_use_pin = fields.Boolean(string='Employee PIN Identification')
|
||||
attendance_from_systray = fields.Boolean(string='Attendance From Systray', default=True)
|
||||
|
||||
@api.depends("attendance_kiosk_key")
|
||||
def _compute_attendance_kiosk_url(self):
|
||||
for company in self:
|
||||
company.attendance_kiosk_url = url_join(company.get_base_url(), '/hr_attendance/%s' % company.attendance_kiosk_key)
|
||||
|
||||
def write(self, vals):
|
||||
search_domain = False # Overtime to generate
|
||||
@@ -72,3 +83,16 @@ class ResCompany(models.Model):
|
||||
self.env['hr.attendance'].search(search_domain)._update_overtime()
|
||||
|
||||
return res
|
||||
|
||||
def _regenerate_attendance_kiosk_key(self):
|
||||
self.ensure_one()
|
||||
self.write({
|
||||
'attendance_kiosk_key': uuid.uuid4().hex
|
||||
})
|
||||
|
||||
def _action_open_kiosk_mode(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'target': 'self',
|
||||
'url': '/hr_attendance/kiosk_mode_menu'
|
||||
}
|
||||
|
||||
@@ -7,9 +7,6 @@ from odoo import api, fields, models
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
group_attendance_use_pin = fields.Boolean(
|
||||
string='Employee PIN',
|
||||
implied_group="hr_attendance.group_hr_attendance_use_pin")
|
||||
hr_attendance_overtime = fields.Boolean(
|
||||
string="Count Extra Hours", readonly=False)
|
||||
overtime_start_date = fields.Date(string="Extra Hours Starting Date", readonly=False)
|
||||
@@ -17,9 +14,13 @@ class ResConfigSettings(models.TransientModel):
|
||||
string="Tolerance Time In Favor Of Company", readonly=False)
|
||||
overtime_employee_threshold = fields.Integer(
|
||||
string="Tolerance Time In Favor Of Employee", readonly=False)
|
||||
hr_attendance_display_overtime = fields.Boolean(related='company_id.hr_attendance_display_overtime', readonly=False)
|
||||
attendance_kiosk_mode = fields.Selection(related='company_id.attendance_kiosk_mode', readonly=False)
|
||||
attendance_barcode_source = fields.Selection(related='company_id.attendance_barcode_source', readonly=False)
|
||||
attendance_kiosk_delay = fields.Integer(related='company_id.attendance_kiosk_delay', readonly=False)
|
||||
attendance_kiosk_url = fields.Char(related='company_id.attendance_kiosk_url')
|
||||
attendance_kiosk_use_pin = fields.Boolean(related='company_id.attendance_kiosk_use_pin', readonly=False)
|
||||
attendance_from_systray = fields.Boolean(related="company_id.attendance_from_systray", readonly=False)
|
||||
|
||||
@api.model
|
||||
def get_values(self):
|
||||
@@ -47,3 +48,7 @@ class ResConfigSettings(models.TransientModel):
|
||||
]
|
||||
if any(self[field] != company[field] for field in fields_to_check):
|
||||
company.write({field: self[field] for field in fields_to_check})
|
||||
|
||||
def regenerate_kiosk_key(self):
|
||||
if self.user_has_groups("hr_attendance.group_hr_attendance_manager"):
|
||||
self.company_id._regenerate_attendance_kiosk_key()
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
from odoo import models, fields, _
|
||||
|
||||
class User(models.Model):
|
||||
_inherit = ['res.users']
|
||||
@@ -13,6 +12,8 @@ class User(models.Model):
|
||||
last_check_in = fields.Datetime(related='employee_id.last_attendance_id.check_in')
|
||||
last_check_out = fields.Datetime(related='employee_id.last_attendance_id.check_out')
|
||||
total_overtime = fields.Float(related='employee_id.total_overtime')
|
||||
attendance_manager_id = fields.Many2one(related='employee_id.attendance_manager_id', readonly=False)
|
||||
display_extra_hours = fields.Boolean(related='company_id.hr_attendance_display_overtime')
|
||||
|
||||
@property
|
||||
def SELF_READABLE_FIELDS(self):
|
||||
@@ -22,5 +23,41 @@ class User(models.Model):
|
||||
'attendance_state',
|
||||
'last_check_in',
|
||||
'last_check_out',
|
||||
'total_overtime'
|
||||
'total_overtime',
|
||||
'attendance_manager_id',
|
||||
'display_extra_hours',
|
||||
]
|
||||
|
||||
def _clean_attendance_officers(self):
|
||||
attendance_officers = self.env['hr.employee'].search(
|
||||
[('attendance_manager_id', 'in', self.ids)]).attendance_manager_id
|
||||
officers_to_remove_ids = self - attendance_officers
|
||||
if officers_to_remove_ids:
|
||||
self.env.ref('hr_attendance.group_hr_attendance_officer').users = [(3, user.id) for user in
|
||||
officers_to_remove_ids]
|
||||
def action_open_last_month_attendances(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": _("Attendances This Month"),
|
||||
"res_model": "hr.attendance",
|
||||
"views": [[self.env.ref('hr_attendance.hr_attendance_employee_simple_tree_view').id, "tree"]],
|
||||
"context": {
|
||||
"create": 0
|
||||
},
|
||||
"domain": [('employee_id', '=', self.employee_id.id),
|
||||
('check_in', ">=", fields.datetime.today().replace(day=1, hour=0, minute=0))]
|
||||
}
|
||||
|
||||
def action_open_last_month_overtime(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": _("Overtime"),
|
||||
"res_model": "hr.attendance.overtime",
|
||||
"views": [[False, "tree"]],
|
||||
"context": {
|
||||
"create": 0
|
||||
},
|
||||
"domain": [('employee_id', '=', self.employee_id.id)]
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import hr_attendance_report
|
||||
@@ -1,73 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, tools
|
||||
|
||||
|
||||
class HRAttendanceReport(models.Model):
|
||||
_name = "hr.attendance.report"
|
||||
_description = "Attendance Statistics"
|
||||
_auto = False
|
||||
|
||||
department_id = fields.Many2one('hr.department', string="Department", readonly=True)
|
||||
employee_id = fields.Many2one('hr.employee', string="Employee", readonly=True)
|
||||
company_id = fields.Many2one('res.company', string="Company", readonly=True)
|
||||
check_in = fields.Date("Check In", readonly=True)
|
||||
worked_hours = fields.Float("Hours Worked", readonly=True)
|
||||
overtime_hours = fields.Float("Extra Hours", readonly=True)
|
||||
|
||||
@api.model
|
||||
def _select(self):
|
||||
return """
|
||||
SELECT
|
||||
hra.id,
|
||||
hr_employee.department_id,
|
||||
hra.employee_id,
|
||||
hr_employee.company_id,
|
||||
hra.check_in,
|
||||
hra.worked_hours,
|
||||
coalesce(ot.duration, 0) as overtime_hours
|
||||
"""
|
||||
|
||||
@api.model
|
||||
def _from(self):
|
||||
return """
|
||||
FROM (
|
||||
SELECT
|
||||
id,
|
||||
row_number() over (partition by employee_id, CAST(check_in AS DATE)) as ot_check,
|
||||
employee_id,
|
||||
CAST(check_in
|
||||
at time zone 'utc'
|
||||
at time zone
|
||||
(SELECT calendar.tz FROM resource_calendar as calendar
|
||||
INNER JOIN hr_employee as employee ON employee.id = hr_attendance.employee_id
|
||||
WHERE calendar.id = employee.resource_calendar_id)
|
||||
as DATE) as check_in,
|
||||
worked_hours
|
||||
FROM
|
||||
hr_attendance
|
||||
) as hra
|
||||
"""
|
||||
|
||||
def _join(self):
|
||||
return """
|
||||
LEFT JOIN hr_employee ON hr_employee.id = hra.employee_id
|
||||
LEFT JOIN hr_attendance_overtime ot
|
||||
ON hra.ot_check = 1
|
||||
AND ot.employee_id = hra.employee_id
|
||||
AND ot.date = hra.check_in
|
||||
AND ot.adjustment = FALSE
|
||||
"""
|
||||
|
||||
def init(self):
|
||||
tools.drop_view_if_exists(self.env.cr, self._table)
|
||||
|
||||
self.env.cr.execute("""
|
||||
CREATE OR REPLACE VIEW %s AS (
|
||||
%s
|
||||
%s
|
||||
%s
|
||||
)
|
||||
""" % (self._table, self._select(), self._from(), self._join())
|
||||
)
|
||||
@@ -1,71 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="hr_attendance_report_view_search" model="ir.ui.view">
|
||||
<field name="name">hr.attendance.report.view.search</field>
|
||||
<field name="model">hr.attendance.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="HR Attendance Search">
|
||||
<field name="employee_id"/>
|
||||
<field name="department_id" operator="child_of"/>
|
||||
<filter name="check_in" string="Check In" date="check_in" default_period="last_month"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Employee" name="groupby_employee" context="{'group_by': 'employee_id'}"/>
|
||||
<filter string="Check In" name="groupby_check_in" context="{'group_by': 'check_in'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_report_view_pivot" model="ir.ui.view">
|
||||
<field name="name">hr.attendance.report.view.pivot</field>
|
||||
<field name="model">hr.attendance.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot string="Attendance" js_class="attendance_report_pivot">
|
||||
<field name="employee_id" type="row"/>
|
||||
<field name="check_in" type="col"/>
|
||||
<field name="worked_hours" type="measure" widget="float_time"/>
|
||||
<field name="overtime_hours" type="measure" widget="float_time"/>
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_report_view_graph" model="ir.ui.view">
|
||||
<field name="name">hr.attendance.report.view.graph</field>
|
||||
<field name="model">hr.attendance.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Attendance Statistics" stacked="0" js_class="attendance_report_graph">
|
||||
<field name="employee_id"/>
|
||||
<field name="check_in"/>
|
||||
<field name="overtime_hours" type="measure" />
|
||||
<field name="worked_hours" type="measure" />
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_report_action" model="ir.actions.act_window">
|
||||
<field name="name">Attendance Analysis</field>
|
||||
<field name="res_model">hr.attendance.report</field>
|
||||
<field name="view_mode">graph,pivot</field>
|
||||
<field name="search_view_id" ref="hr_attendance_report_view_search"/>
|
||||
<field name="context">{'group_by': ['check_in:day', 'employee_id'], 'search_default_check_in': '1'}</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_report_action_filtered" model="ir.actions.act_window">
|
||||
<field name="name">Attendance Analysis</field>
|
||||
<field name="res_model">hr.attendance.report</field>
|
||||
<field name="view_mode">graph,pivot</field>
|
||||
<field name="search_view_id" ref="hr_attendance_report_view_search"/>
|
||||
<field name="context">{
|
||||
'group_by': ['check_in:day', 'employee_id'],
|
||||
'search_default_department_id': [active_id]}
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
id="menu_hr_attendance_report"
|
||||
name="Reporting"
|
||||
sequence="30"
|
||||
parent="hr_attendance.menu_hr_attendance_root"
|
||||
action="hr_attendance_report_action"
|
||||
groups="hr_attendance.group_hr_attendance_user"/>
|
||||
</odoo>
|
||||
@@ -4,48 +4,36 @@
|
||||
<field name="sequence">14</field>
|
||||
</record>
|
||||
|
||||
<record id="group_hr_attendance_kiosk" model="res.groups">
|
||||
<field name="name">User: Only kiosk mode</field>
|
||||
<field name="category_id" ref="base.module_category_human_resources_employees"/>
|
||||
<field name="comment">The user will be able to open the kiosk mode and validate the employee PIN.</field>
|
||||
<field name="implied_ids" eval="[(6, 0, [ref('base.group_user')])]"/>
|
||||
<record id="group_hr_attendance_own_reader" model="res.groups">
|
||||
<field name="name">User: Read his own attendances</field>
|
||||
<field name="category_id" ref="base.module_category_hidden"/>
|
||||
<field name="comment">The user will have access to his own attendances on his user / employee profile</field>
|
||||
</record>
|
||||
|
||||
<record id="hr.group_hr_user" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('hr_attendance.group_hr_attendance_kiosk'))]"/>
|
||||
<record id="base.group_user" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('hr_attendance.group_hr_attendance_own_reader'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="group_hr_attendance" model="res.groups">
|
||||
<field name="name">User</field>
|
||||
<field name="category_id" ref="base.module_category_human_resources_attendances"/>
|
||||
<field name="comment">The user will gain access to the human resources attendance menu, enabling him to manage his own attendance.</field>
|
||||
</record>
|
||||
|
||||
<record id="group_hr_attendance_user" model="res.groups">
|
||||
<field name="name">Officer: Manage all attendances</field>
|
||||
<field name="category_id" ref="base.module_category_human_resources_attendances"/>
|
||||
<field name="implied_ids" eval="[(4, ref('hr.group_hr_user')), (4, ref('hr_attendance.group_hr_attendance'))]"/>
|
||||
<record id="group_hr_attendance_officer" model="res.groups">
|
||||
<field name="name">Officer: Manage attendances</field>
|
||||
<field name="category_id" ref="base.module_category_hidden"/>
|
||||
<field name="comment">The user will have access to the attendance records and reporting of employees where he's set as an attendance manager</field>
|
||||
</record>
|
||||
|
||||
<record id="group_hr_attendance_manager" model="res.groups">
|
||||
<field name="name">Administrator</field>
|
||||
<field name="category_id" ref="base.module_category_human_resources_attendances"/>
|
||||
<field name="implied_ids" eval="[(4, ref('hr_attendance.group_hr_attendance_user'))]"/>
|
||||
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||
<field name="implied_ids" eval="[(4, ref('hr_attendance.group_hr_attendance_officer'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="base.default_user" model="res.users">
|
||||
<field name="groups_id" eval="[(4,ref('hr_attendance.group_hr_attendance_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="group_hr_attendance_use_pin" model="res.groups">
|
||||
<field name="name">Enable PIN use</field>
|
||||
<field name="category_id" ref="base.module_category_hidden"/>
|
||||
<field name="comment">The user will have to enter his PIN to check in and out manually at the company screen.</field>
|
||||
<field name="groups_id" eval="[(4, ref('hr_attendance.group_hr_attendance_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- Attendances -->
|
||||
<record id="hr_attendance_rule_employee_company" model="ir.rule">
|
||||
<field name="name">Employee multi company rule</field>
|
||||
<field name="model_id" ref="model_hr_attendance"/>
|
||||
@@ -53,33 +41,43 @@
|
||||
<field name="domain_force">['|',('employee_id.company_id','=',False),('employee_id.company_id', 'in', company_ids)]</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_rule_attendance_manager" model="ir.rule">
|
||||
<field name="name">attendance officer: full access</field>
|
||||
<record id="hr_attendance_rule_attendance_admin" model="ir.rule">
|
||||
<field name="name">Attendance Administrator: Full access</field>
|
||||
<field name="model_id" ref="model_hr_attendance"/>
|
||||
<field name="domain_force">[(1,'=',1)]</field>
|
||||
<field name="groups" eval="[(4,ref('hr_attendance.group_hr_attendance_user'))]"/>
|
||||
<field name="groups" eval="[(4, ref('hr_attendance.group_hr_attendance_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_rule_attendance_manual" model="ir.rule">
|
||||
<field name="name">Manual Attendance: own attendances</field>
|
||||
<record id="hr_attendance_rule_attendance_manager_restrict" model="ir.rule">
|
||||
<field name="name">Attendance Officer: Restrict Attendances to managed employees</field>
|
||||
<field name="model_id" ref="model_hr_attendance"/>
|
||||
<field name="domain_force">
|
||||
[
|
||||
'|',
|
||||
'&',
|
||||
('employee_id.attendance_manager_id', '=', user.id),
|
||||
('employee_id.user_id', '=', user.id),
|
||||
'&',
|
||||
('employee_id.user_id', '!=', user.id),
|
||||
('employee_id.attendance_manager_id', '=', user.id)
|
||||
]
|
||||
</field>
|
||||
<field name="groups" eval="[(4, ref('hr_attendance.group_hr_attendance_officer'))]"/>
|
||||
<field name="perm_create" eval="1"/>
|
||||
<field name="perm_write" eval="1"/>
|
||||
<field name="perm_unlink" eval="1"/>
|
||||
<field name="perm_read" eval="1"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_rule_attendance_simple_user" model="ir.rule">
|
||||
<field name="name">Attendance base user: Read his own attendances in other apps</field>
|
||||
<field name="model_id" ref="model_hr_attendance"/>
|
||||
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
|
||||
<field name="perm_read" eval="1"/>
|
||||
<field name="perm_write" eval="1"/>
|
||||
<field name="perm_create" eval="1"/>
|
||||
<field name="perm_unlink" eval="0"/>
|
||||
<field name="groups" eval="[(4,ref('hr_attendance.group_hr_attendance'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_rule_attendance_employee" model="ir.rule">
|
||||
<field name="name">user: read own attendance only</field>
|
||||
<field name="model_id" ref="model_hr_attendance"/>
|
||||
<field name="domain_force">[('employee_id.user_id','=',user.id)]</field>
|
||||
<field name="perm_read" eval="1"/>
|
||||
<field name="perm_write" eval="0"/>
|
||||
<field name="groups" eval="[(4, ref('hr_attendance.group_hr_attendance_own_reader'))]"/>
|
||||
<field name="perm_create" eval="0"/>
|
||||
<field name="perm_write" eval="0"/>
|
||||
<field name="perm_unlink" eval="0"/>
|
||||
<field name="groups" eval="[(4,ref('base.group_user'))]"/>
|
||||
<field name="perm_read" eval="1"/>
|
||||
</record>
|
||||
|
||||
<!-- Overtime -->
|
||||
@@ -90,29 +88,44 @@
|
||||
<field name="domain_force">['|',('employee_id.company_id','=',False),('employee_id.company_id', 'in', company_ids)]</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_rule_attendance_overtime_manager" model="ir.rule">
|
||||
<field name="name">attendance officer: full access</field>
|
||||
<record id="hr_attendance_rule_attendance_officer_overtime_restrict" model="ir.rule">
|
||||
<field name="name">Attendance Officer: Restrict Overtime to managed employees</field>
|
||||
<field name="model_id" ref="model_hr_attendance_overtime"/>
|
||||
<field name="domain_force">
|
||||
[
|
||||
'|',
|
||||
'&',
|
||||
('employee_id.attendance_manager_id', '=', user.id),
|
||||
('employee_id.user_id', '=', user.id),
|
||||
'&',
|
||||
('employee_id.user_id', '!=', user.id),
|
||||
('employee_id.attendance_manager_id', '=', user.id)
|
||||
]
|
||||
</field>
|
||||
<field name="groups" eval="[(4, ref('hr_attendance.group_hr_attendance_officer'))]"/>
|
||||
<field name="perm_create" eval="1"/>
|
||||
<field name="perm_write" eval="1"/>
|
||||
<field name="perm_unlink" eval="1"/>
|
||||
<field name="perm_read" eval="1"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_rule_attendance_overtime_admin" model="ir.rule">
|
||||
<field name="name">Attendance Admin: full access</field>
|
||||
<field name="model_id" ref="model_hr_attendance_overtime"/>
|
||||
<field name="domain_force">[(1,'=',1)]</field>
|
||||
<field name="groups" eval="[(4,ref('hr_attendance.group_hr_attendance_user'))]"/>
|
||||
<field name="groups" eval="[(4, ref('hr_attendance.group_hr_attendance_manager'))]"/>
|
||||
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_rule_attendance_overtime_employee" model="ir.rule">
|
||||
<field name="name">user: read and modify own attendance only</field>
|
||||
<record id="hr_attendance_rule_attendance_overtime_simple_user" model="ir.rule">
|
||||
<field name="name">Attendance base user: Read his own overtime</field>
|
||||
<field name="model_id" ref="model_hr_attendance_overtime"/>
|
||||
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
|
||||
<field name="perm_read" eval="1"/>
|
||||
<field name="perm_write" eval="0"/>
|
||||
<field name="groups" eval="[(4, ref('hr_attendance.group_hr_attendance_own_reader'))]"/>
|
||||
<field name="perm_create" eval="0"/>
|
||||
<field name="perm_write" eval="0"/>
|
||||
<field name="perm_unlink" eval="0"/>
|
||||
<field name="groups" eval="[(4,ref('base.group_user'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Report -->
|
||||
<record id="hr_attendance_report_rule_multi_company" model="ir.rule">
|
||||
<field name="name">Attendance report multi company rule</field>
|
||||
<field name="model_id" ref="model_hr_attendance_report"/>
|
||||
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
|
||||
<field name="perm_read" eval="1"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_hr_attendance_officer,hr.attendance.user,model_hr_attendance,group_hr_attendance_user,1,1,1,1
|
||||
access_hr_attendance_user,hr.attendance.system.user,model_hr_attendance,group_hr_attendance,1,1,1,0
|
||||
access_hr_attendance_system_user,hr.attendance.system.user,model_hr_attendance,base.group_user,1,0,0,0
|
||||
access_hr_attendance_overtime_user,hr.attendance.overtime.user,model_hr_attendance_overtime,hr_attendance.group_hr_attendance_user,1,1,1,1
|
||||
access_hr_attendance_overtime_system_user,hr.attendance.overtime.system.user,model_hr_attendance_overtime,base.group_user,1,0,0,0
|
||||
access_hr_attendance_report_user,hr.attendance.report.user,model_hr_attendance_report,hr_attendance.group_hr_attendance_user,1,0,0,0
|
||||
access_hr_attendance_admin,hr.attendance.admin,model_hr_attendance,group_hr_attendance_manager,1,1,1,1
|
||||
access_hr_attendance_admin_overtime,hr.attendance.admin.overtime,model_hr_attendance_overtime,group_hr_attendance_manager,1,1,1,1
|
||||
access_hr_attendance_officer,hr.attendance.officer,model_hr_attendance,group_hr_attendance_officer,1,1,1,1
|
||||
access_hr_attendance_officer_overtime,hr.attendance.officer.overtime,model_hr_attendance_overtime,group_hr_attendance_officer,1,1,1,1
|
||||
access_hr_attendance_user,hr.attendance.user,model_hr_attendance,group_hr_attendance_own_reader,1,0,0,0
|
||||
access_hr_attendance_overtime_user,hr.attendance.overtime.user,model_hr_attendance_overtime,group_hr_attendance_own_reader,1,0,0,0
|
||||
|
||||
|
@@ -0,0 +1,35 @@
|
||||
<svg width="1920" height="1080" viewBox="0 0 1920 1080" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.51001 1080H76.35L1153.55 0H3.51001V1080Z" fill="url(#o_app_switcher_gradient_01)"/>
|
||||
<path d="M76.35 1080H842.98L1920 0.18V0H1153.55L76.35 1080Z" fill="url(#o_app_switcher_gradient_02)"/>
|
||||
<path d="M1920 0.180176L842.98 1080H1063.11L1920 220.88V0.180176Z" fill="url(#o_app_switcher_gradient_03)"/>
|
||||
<path d="M1920 1080V220.88L1063.11 1080H1920Z" fill="url(#o_app_switcher_gradient_04)"/>
|
||||
<rect width="1920" height="1080" fill="url(#o_app_switcher_gradient_05)" fill-opacity="0.25"/>
|
||||
<rect width="1920" height="1080" fill="#E9E6F9" fill-opacity="0.25"/>
|
||||
<defs>
|
||||
<linearGradient id="o_app_switcher_gradient_01" x1="-222.43" y1="727.19" x2="904.26" y2="-76.67" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.1" stop-color="white"/>
|
||||
<stop offset="0.36" stop-color="#FEFEFE"/>
|
||||
<stop offset="0.68" stop-color="#EAE7F9"/>
|
||||
<stop offset="1" stop-color="#E4E9F7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="o_app_switcher_gradient_02" x1="407.23" y1="1021.82" x2="1848.47" y2="-153.08" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.32" stop-color="#FEFEFE"/>
|
||||
<stop offset="0.66" stop-color="#EAE7F9"/>
|
||||
<stop offset="1" stop-color="#E5E2F6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="o_app_switcher_gradient_03" x1="1142.33" y1="846.57" x2="1951.83" y2="136.16" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.15" stop-color="white"/>
|
||||
<stop offset="0.51" stop-color="#F7F0FD"/>
|
||||
<stop offset="0.85" stop-color="#F0E7F9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="o_app_switcher_gradient_04" x1="1409.74" y1="1071" x2="2070.98" y2="526.01" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.45" stop-color="white"/>
|
||||
<stop offset="0.88" stop-color="#F7F0FD"/>
|
||||
<stop offset="1" stop-color="#ECE5F8"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="o_app_switcher_gradient_05" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(960 540) rotate(90) scale(540 960)">
|
||||
<stop stop-color="#9996A9" stop-opacity="0.53"/>
|
||||
<stop offset="1" stop-color="#7A768F"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -1,224 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { CardLayout } from "@hr_attendance/components/card_layout/card_layout";
|
||||
import { Component, onWillStart, onWillUnmount, useState } from "@odoo/owl";
|
||||
import { deserializeDateTime } from "@web/core/l10n/dates";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useBus, useService } from "@web/core/utils/hooks";
|
||||
|
||||
const { Duration } = luxon;
|
||||
|
||||
export class GreetingMessage extends Component {
|
||||
setup() {
|
||||
const { action } = this.props;
|
||||
|
||||
this.actionService = useService("action");
|
||||
this.notificationService = useService("notification");
|
||||
this.orm = useService("orm");
|
||||
this.user = useService("user");
|
||||
|
||||
this.kioskDelay = action.kiosk_delay;
|
||||
this.state = useState({
|
||||
message: "",
|
||||
randomMessage: "",
|
||||
showWarningMessage: false,
|
||||
});
|
||||
|
||||
onWillStart(this.onWillStart);
|
||||
onWillUnmount(() => this.return_to_main_menu && clearTimeout(this.return_to_main_menu));
|
||||
|
||||
let activeBarcode = true;
|
||||
if (!this.props.action.attendance) {
|
||||
activeBarcode = false;
|
||||
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"
|
||||
) {
|
||||
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 && deserializeDateTime(this.attendance.check_in);
|
||||
this.attendance.check_out =
|
||||
this.attendance.check_out && deserializeDateTime(this.attendance.check_out);
|
||||
this.previous_attendance_change_date =
|
||||
action.previous_attendance_change_date &&
|
||||
deserializeDateTime(action.previous_attendance_change_date);
|
||||
|
||||
const formatDateTime = registry.category("formatters").get("datetime");
|
||||
this.attendance.check_in_time =
|
||||
this.attendance.check_in && formatDateTime(this.attendance.check_in);
|
||||
this.attendance.check_out_time =
|
||||
this.attendance.check_out && formatDateTime(this.attendance.check_out);
|
||||
|
||||
const formatFloatTime = registry.category("formatters").get("float_time");
|
||||
// extra hours amount displayed in the greeting message template.
|
||||
this.total_overtime_float = action.total_overtime; // Used for comparison in template
|
||||
this.total_overtime = formatFloatTime(this.total_overtime_float);
|
||||
this.today_overtime_float = action.overtime_today;
|
||||
this.today_overtime = formatFloatTime(this.today_overtime_float);
|
||||
|
||||
if (action.hours_today) {
|
||||
const duration = Duration.fromObject({ hours: action.hours_today })
|
||||
.toFormat("hh-mm")
|
||||
.split("-");
|
||||
|
||||
this.hours_today = _t("%(hours)s hours, %(minutes)s minutes", {
|
||||
hours: duration[0],
|
||||
minutes: duration[1],
|
||||
});
|
||||
}
|
||||
|
||||
this.employee_name = action.employee_name;
|
||||
this.attendanceBarcode = action.barcode;
|
||||
|
||||
const barcode = useService("barcode");
|
||||
this.lockScanner = false;
|
||||
if (activeBarcode) {
|
||||
useBus(barcode.bus, "barcode_scanned", this.onBarcodeScanned.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
async onBarcodeScanned(ev) {
|
||||
if (this.lockScanner) {
|
||||
return;
|
||||
}
|
||||
const { barcode } = ev.detail;
|
||||
if (this.attendanceBarcode !== barcode) {
|
||||
this.lockScanner = true;
|
||||
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);
|
||||
}
|
||||
|
||||
const result = await this.orm.call("hr.employee", "attendance_scan", [barcode]);
|
||||
if (result.action) {
|
||||
this.actionService.doAction(result.action);
|
||||
} else if (result.warning) {
|
||||
this.notificationService.add(result.warning, { type: "danger" });
|
||||
this.setTimeoutNextAction();
|
||||
this.lockScanner = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClickDismiss() {
|
||||
this.actionService.doAction(this.next_action, { clearBreadcrumbs: true });
|
||||
}
|
||||
|
||||
async onWillStart() {
|
||||
// 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 (!this.attendance) {
|
||||
const hasGroup = await this.user.hasGroup("hr_attendance.group_hr_attendance_user");
|
||||
if (hasGroup) {
|
||||
this.next_action = "hr_attendance.hr_attendance_action_kiosk_mode";
|
||||
} else {
|
||||
this.next_action = "hr_attendance.hr_attendance_action_my_attendances";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.setTimeoutNextAction();
|
||||
this.attendance.check_out ? this.setFarewellMessage() : this.setWelcomeMessage();
|
||||
}
|
||||
|
||||
setWelcomeMessage() {
|
||||
const now = this.attendance.check_in;
|
||||
if (now.hour < 5) {
|
||||
this.state.message = _t("Good night");
|
||||
} else if (now.hour < 12) {
|
||||
if (now.hour < 8 && Math.random() < 0.3) {
|
||||
if (Math.random() < 0.75) {
|
||||
this.state.message = _t("The early bird catches the worm");
|
||||
} else {
|
||||
this.state.message = _t("First come, first served");
|
||||
}
|
||||
} else {
|
||||
this.state.message = _t("Good morning");
|
||||
}
|
||||
} else if (now.hour < 17) {
|
||||
this.state.message = _t("Good afternoon");
|
||||
} else if (now.hour < 23) {
|
||||
this.state.message = _t("Good evening");
|
||||
} else {
|
||||
this.state.message = _t("Good night");
|
||||
}
|
||||
if (this.previous_attendance_change_date) {
|
||||
const last_check_out_date = this.previous_attendance_change_date;
|
||||
if (now - last_check_out_date > 24 * 7 * 60 * 60 * 1000) {
|
||||
this.state.randomMessage = _t("Glad to have you back, it's been a while!");
|
||||
} else {
|
||||
if (Math.random() < 0.02) {
|
||||
this.state.randomMessage = _t(
|
||||
"If a job is worth doing, it is worth doing well!"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setFarewellMessage() {
|
||||
const now = this.attendance.check_out;
|
||||
|
||||
if (this.previous_attendance_change_date) {
|
||||
const last_check_in_date = this.previous_attendance_change_date;
|
||||
if (now - last_check_in_date > 1000 * 60 * 60 * 12) {
|
||||
this.state.showWarningMessage = true;
|
||||
if (this.return_to_main_menu) {
|
||||
clearTimeout(this.return_to_main_menu);
|
||||
}
|
||||
this.lockScanner = true;
|
||||
} else if (now - last_check_in_date > 1000 * 60 * 60 * 8) {
|
||||
this.state.randomMessage = _t("Another good day's work! See you soon!");
|
||||
}
|
||||
}
|
||||
|
||||
if (now.hour < 12) {
|
||||
this.state.message = _t("Have a good day!");
|
||||
} else if (now.hour < 14) {
|
||||
this.state.message = _t("Have a nice lunch!");
|
||||
if (Math.random() < 0.05) {
|
||||
this.state.randomMessage = _t(
|
||||
"Eat breakfast as a king, lunch as a merchant and supper as a beggar"
|
||||
);
|
||||
} else if (Math.random() < 0.06) {
|
||||
this.state.randomMessage = _t("An apple a day keeps the doctor away");
|
||||
}
|
||||
} else if (now.hour < 17) {
|
||||
this.state.message = _t("Have a good afternoon");
|
||||
} else {
|
||||
if (now.hour < 18 && Math.random() < 0.2) {
|
||||
this.state.message = _t(
|
||||
"Early to bed and early to rise, makes a man healthy, wealthy and wise"
|
||||
);
|
||||
} else {
|
||||
this.state.message = _t("Have a good evening");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTimeoutNextAction() {
|
||||
if (this.kioskDelay > 0) {
|
||||
this.return_to_main_menu = setTimeout(() => {
|
||||
this.actionService.doAction(this.next_action, { clearBreadcrumbs: true });
|
||||
}, this.kioskDelay);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
GreetingMessage.template = "hr_attendance.GreetingMessage";
|
||||
GreetingMessage.components = { CardLayout };
|
||||
|
||||
registry.category("actions").add("hr_attendance_greeting_message", GreetingMessage);
|
||||
@@ -1,63 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr_attendance.GreetingMessage">
|
||||
<CardLayout>
|
||||
<t t-if="attendance">
|
||||
<t t-call="hr_attendance.UserBadge">
|
||||
<t t-set="userId" t-value="attendance.employee_id[0]"/>
|
||||
<t t-set="userName" t-value="employee_name"/>
|
||||
</t>
|
||||
|
||||
<div t-if="attendance.check_out" class="flex-grow-1">
|
||||
<h1 class="mt-5">Goodbye <t t-esc="employee_name"/>!</h1>
|
||||
<h2 class="o_hr_attendance_message_message mt4 mb24" t-esc="state.message"/>
|
||||
<div class="alert alert-info fs-2 mx-3" role="status">
|
||||
Checked out at <b><t t-esc="attendance.check_out_time"/></b>
|
||||
<br/><b><t t-esc="hours_today"/></b>
|
||||
</div>
|
||||
<div t-att-class="'alert ' + (today_overtime_float >= 0 ? 'alert-success' : 'alert-danger') + ' h3 mx-3'" role="status">
|
||||
Extra hours today:
|
||||
<span t-esc="today_overtime"/>
|
||||
</div>
|
||||
<t t-if="total_overtime_float > 0">
|
||||
Total extra hours: <span t-esc="total_overtime"/>
|
||||
</t>
|
||||
<h3 class="o_hr_attendance_random_message fst-italic mb24" t-esc="state.randomMessage"/>
|
||||
<div t-if="showWarningMessage" class="o_hr_attendance_warning_message mt24 alert alert-warning" role="alert">
|
||||
<b>Warning! Last check in was over 12 hours ago.</b><br/>If this isn't right, please contact Human Resource staff
|
||||
</div>
|
||||
</div>
|
||||
<div t-else="" class="flex-grow-1">
|
||||
<h1 class="mt-5 mb0">Welcome <t t-esc="employee_name"/>!</h1>
|
||||
<h2 class="o_hr_attendance_message_message mt4 mb24" t-esc="state.message"/>
|
||||
<div class="alert alert-info fs-2 mx-3" role="status">
|
||||
Checked in at <b><t t-esc="attendance.check_in_time"/></b>
|
||||
</div>
|
||||
<h3 class="o_hr_attendance_random_message mb24" t-esc="state.randomMessage"/>
|
||||
<div class="o_hr_attendance_warning_message mt24 alert alert-warning" style="display:none" role="alert"/>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<button class="o_hr_attendance_button_dismiss align-self-center btn btn-primary btn-lg px-5 py-3" t-on-click="() => this.onClickDismiss()">
|
||||
<span class="fs-2" t-if="attendance.check_out">Goodbye</span>
|
||||
<span class="fs-2" t-else="">OK</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="flex-grow-1">
|
||||
<div class="alert alert-warning mt-5 mx-3" role="alert">
|
||||
<h4 class="alert-heading">Invalid request</h4>
|
||||
<p>Please return to the main menu.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<button class="o_hr_attendance_button_dismiss btn btn-primary btn-lg fs-2 px-5 py-3" t-on-click="() => this.onClickDismiss()">
|
||||
<i class="oi oi-chevron-left me-2"/>
|
||||
<span class="fs-2">Go back</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</CardLayout>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -1,109 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { CardLayout } from "@hr_attendance/components/card_layout/card_layout";
|
||||
import { CheckInOut } from "@hr_attendance/components/check_in_out/check_in_out";
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class KioskConfirm extends Component {
|
||||
setup() {
|
||||
this.actionService = useService("action");
|
||||
this.notification = useService("notification");
|
||||
this.orm = useService("orm");
|
||||
this.user = useService("user");
|
||||
|
||||
this.padButtons = [
|
||||
...Array.from({ length: 9 }, (_, i) => [i + 1]), // [[1], ..., [9]]
|
||||
["C", "btn-warning"],
|
||||
[0],
|
||||
["OK", "btn-primary"],
|
||||
];
|
||||
this.state = useState({
|
||||
codePin: "",
|
||||
});
|
||||
this.lockPad = false;
|
||||
|
||||
this.nextAction = "hr_attendance.hr_attendance_action_kiosk_mode";
|
||||
const { employee_id, employee_name, employee_state, employee_hours_today } =
|
||||
this.props.action;
|
||||
this.employeeId = employee_id;
|
||||
this.employeeName = employee_name;
|
||||
this.checkedIn = employee_state === "checked_in";
|
||||
this.employeeHoursToday = registry.category("formatters").get("float_time")(
|
||||
employee_hours_today
|
||||
);
|
||||
|
||||
onWillStart(this.onWillStart);
|
||||
}
|
||||
|
||||
async onWillStart() {
|
||||
this.use_pin = await this.user.hasGroup("hr_attendance.group_hr_attendance_use_pin");
|
||||
|
||||
if (this.use_pin) {
|
||||
browser.addEventListener('keydown', async (ev) => {
|
||||
const allowedKeys = [...Array(10).keys()].reduce((acc, value) => { // { from '0': '0' ... to '9': '9' }
|
||||
acc[value] = value;
|
||||
return acc;
|
||||
}, {
|
||||
'Delete': 'C',
|
||||
'Enter': 'OK',
|
||||
'Backspace': null,
|
||||
});
|
||||
const key = ev.key;
|
||||
|
||||
if (!Object.keys(allowedKeys).includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (allowedKeys[key]) {
|
||||
await this.onClickPadButton(allowedKeys[key]);
|
||||
}
|
||||
else {
|
||||
this.state.codePin = this.state.codePin.substring(0, this.state.codePin.length - 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onClickBack() {
|
||||
this.actionService.doAction(this.nextAction, { clearBreadcrumbs: true });
|
||||
}
|
||||
|
||||
async onClickPadButton(value) {
|
||||
if (this.lockPad) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === "C") {
|
||||
this.state.codePin = "";
|
||||
} else if (value === "OK") {
|
||||
this.lockPad = true;
|
||||
const result = await this.orm.call("hr.employee", "attendance_manual", [
|
||||
[this.employeeId],
|
||||
this.nextAction,
|
||||
this.state.codePin,
|
||||
]);
|
||||
if (result.action) {
|
||||
this.actionService.doAction(result.action);
|
||||
} else if (result.warning) {
|
||||
this.notification.add(result.warning, { type: "danger" });
|
||||
this.state.codePin = "";
|
||||
browser.setTimeout(() => {
|
||||
this.lockPad = false;
|
||||
}, 500);
|
||||
}
|
||||
} else {
|
||||
this.state.codePin += value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
KioskConfirm.template = "hr_attendance.KioskConfirm";
|
||||
KioskConfirm.components = { CardLayout, CheckInOut };
|
||||
|
||||
registry.category("actions").add("hr_attendance_kiosk_confirm", KioskConfirm);
|
||||
@@ -1,63 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr_attendance.UserBadge">
|
||||
<div class="o_hr_attendance_user_badge o_home_menu_background d-flex align-items-end justify-content-center flex-grow-1 pt-5 pt-md-4 bg-odoo">
|
||||
<img class="img rounded-circle mb-n5" t-attf-src="/web/image?model=hr.employee.public&field=avatar_128&id=#{userId}" t-att-title="userName" height="80" t-att-alt="userName"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="hr_attendance.KioskConfirm">
|
||||
<CardLayout>
|
||||
|
||||
<button class="o_hr_attendance_back_button btn btn-block btn-secondary btn-lg d-block d-md-none py-5">
|
||||
<i class="oi oi-chevron-left me-2"/> Go back
|
||||
</button>
|
||||
|
||||
<t t-if="employeeId" t-call="hr_attendance.UserBadge">
|
||||
<t t-set="userId" t-value="employeeId"/>
|
||||
<t t-set="userName" t-value="employeeName"/>
|
||||
</t>
|
||||
|
||||
<button t-on-click="() => this.onClickBack()" class="o_hr_attendance_back_button o_hr_attendance_back_button_md btn btn-secondary d-none d-md-inline-flex align-items-center position-absolute top-0 start-0 rounded-circle">
|
||||
<i class="oi fa-2x fa-fw oi-chevron-left me-1" role="img" aria-label="Go back" title="Go back"/>
|
||||
</button>
|
||||
|
||||
<div t-if="employeeId" class="flex-grow-1">
|
||||
<h1 class="mt-5 mb8" t-esc="employeeName"/>
|
||||
<h3 class="mt8 mb24"><t t-if="!checkedIn">Welcome!</t><t t-else="">Want to check out?</t></h3>
|
||||
<h4 class="mt0 mb0 text-muted" t-if="checkedIn">Today's work hours: <span t-esc="employeeHoursToday"/></h4>
|
||||
|
||||
<CheckInOut t-if="!use_pin" checkedIn="checkedIn" employeeId="employeeId" nextAction="nextAction"/>
|
||||
|
||||
<t t-else="">
|
||||
<h3 class="mt-4 mb0 text-muted">Please enter your PIN to <b t-if="checkedIn">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 g-0" >
|
||||
<div class="col-12 mb8 mt8">
|
||||
<input t-att-value="state.codePin" class="o_hr_attendance_PINbox border-0 bg-white fs-1 text-center" type="password" disabled="true"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<t t-foreach="padButtons" t-as="btn" t-key="btn[0]">
|
||||
<div class="col-4 p-1">
|
||||
<a href="#" t-on-click="() => this.onClickPadButton(btn[0])" t-attf-class="o_hr_attendance_PINbox_button btn {{btn[1]? btn[1] : 'btn-secondary border'}} btn-block btn-lg d-flex align-items-center justify-content-center">
|
||||
<t t-esc="btn[0]"/>
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div t-else="" class="alert alert-danger mx-3" role="alert">
|
||||
<h4 class="alert-heading">Error: could not find corresponding employee.</h4>
|
||||
<p>Please return to the main menu.</p>
|
||||
</div>
|
||||
<a role="button" class="oe_attendance_sign_in_out" aria-label="Sign out" title="Sign out"/>
|
||||
</CardLayout>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -1,93 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { BarcodeScanner } from "@barcodes/components/barcode_scanner";
|
||||
import { CardLayout } from "@hr_attendance/components/card_layout/card_layout";
|
||||
import { Component, onWillStart } from "@odoo/owl";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useBus, useService } from "@web/core/utils/hooks";
|
||||
import { url } from "@web/core/utils/urls";
|
||||
|
||||
export class KioskMode extends Component {
|
||||
setup() {
|
||||
this.actionService = useService("action");
|
||||
this.companyService = useService("company");
|
||||
this.notification = useService("notification");
|
||||
this.rpc = useService("rpc");
|
||||
this.orm = useService("orm");
|
||||
|
||||
const barcode = useService("barcode");
|
||||
useBus(barcode.bus, "barcode_scanned", (ev) => this.onBarcodeScanned(ev.detail.barcode));
|
||||
|
||||
this.lockScanner = false;
|
||||
|
||||
onWillStart(this.onWillStart);
|
||||
// Make a RPC call every day to keep the session alive
|
||||
browser.setInterval(() => this.callServer(), 60 * 60 * 1000 * 24);
|
||||
}
|
||||
|
||||
async onWillStart() {
|
||||
const companyId = this.companyService.currentCompany.id;
|
||||
const currentCompany = (
|
||||
await this.orm.searchRead(
|
||||
"res.company",
|
||||
[["id", "=", companyId]],
|
||||
["name", "attendance_kiosk_mode", "attendance_barcode_source"][
|
||||
("employee_id", "date_from", "date_to", "number_of_days")
|
||||
]
|
||||
)
|
||||
)[0];
|
||||
|
||||
this.companyName = currentCompany.name;
|
||||
this.companyImageUrl = url("/web/image", {
|
||||
model: "res.company",
|
||||
id: companyId,
|
||||
field: "logo",
|
||||
});
|
||||
this.kioskMode = currentCompany.attendance_kiosk_mode;
|
||||
this.barcodeSource = currentCompany.attendance_barcode_source;
|
||||
}
|
||||
|
||||
onClickAttendanceEmployees() {
|
||||
this.actionService.doAction("hr_attendance.hr_employee_attendance_action_kanban", {
|
||||
additionalContext: { no_group_by: true },
|
||||
});
|
||||
}
|
||||
|
||||
async onBarcodeScanned(barcode) {
|
||||
if (this.lockScanner) {
|
||||
return;
|
||||
}
|
||||
this.lockScanner = true;
|
||||
const result = await this.orm.call("hr.employee", "attendance_scan", [barcode]);
|
||||
if (result.action) {
|
||||
this.actionService.doAction(result.action);
|
||||
} else if (result.warning) {
|
||||
this.notification.add(result.warning, { type: "danger" });
|
||||
this.lockScanner = false;
|
||||
}
|
||||
}
|
||||
|
||||
callServer() {
|
||||
// Make a call to the database to avoid the auto close of the session
|
||||
this.rpc("/hr_attendance/kiosk_keepalive", {});
|
||||
}
|
||||
}
|
||||
|
||||
class KioskModeBarcodeScanner extends BarcodeScanner {
|
||||
get facingMode() {
|
||||
if (this.props.barcodeSource == "front") {
|
||||
return "user";
|
||||
}
|
||||
return super.facingMode;
|
||||
}
|
||||
}
|
||||
KioskModeBarcodeScanner.props = {
|
||||
...BarcodeScanner.props,
|
||||
barcodeSource: String,
|
||||
};
|
||||
|
||||
KioskMode.template = "hr_attendance.KioskMode";
|
||||
KioskMode.components = { CardLayout, BarcodeScanner: KioskModeBarcodeScanner };
|
||||
|
||||
registry.category("actions").add("hr_attendance_kiosk_mode", KioskMode);
|
||||
@@ -1,31 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr_attendance.KioskMode">
|
||||
<CardLayout kioskModeClasses="'o_barcode_main pt-5'">
|
||||
<h2 class="mb-2"><small>Welcome to</small> <t t-esc="companyName"/></h2>
|
||||
<img t-att-src="companyImageUrl" alt="Company Logo" class="o_hr_attendance_kiosk_company_image align-self-center img img-fluid mb-3" width="200"/>
|
||||
<div class="o_hr_attendance_kiosk_welcome_row d-flex flex-column pb-5">
|
||||
<div class="col-md-5 mt-5 mb-5 mb-md-0 align-self-center" t-if="kioskMode != 'manual'">
|
||||
<BarcodeScanner t-if="barcodeSource !== 'scanner'" onBarcodeScanned="(ev) => this.onBarcodeScanned(ev)" barcodeSource="barcodeSource"/>
|
||||
<div t-else="" class="o_barcode_mobile_container">
|
||||
<img src="/barcodes/static/img/barcode.png" alt="Barcode"/>
|
||||
<span class="o_barcode_laser"/>
|
||||
</div>
|
||||
<h6 class="mt-2 text-muted">Scan your badge</h6>
|
||||
</div>
|
||||
<div class="mt-5 align-self-end" t-if="kioskMode == 'barcode_manual'">
|
||||
<button class="o_hr_attendance_button_employees btn btn-link" t-on-click="() => this.onClickAttendanceEmployees()">
|
||||
Identify Manually
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-5 align-self-center" t-if="kioskMode == 'manual'">
|
||||
<button class="o_hr_attendance_button_employees btn btn-primary px-5 py-3 mt-4 mb-2" t-on-click="() => this.onClickAttendanceEmployees()">
|
||||
<span class="fs-2">Identify Manually</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -1,40 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { CardLayout } from "@hr_attendance/components/card_layout/card_layout";
|
||||
import { CheckInOut } from "@hr_attendance/components/check_in_out/check_in_out";
|
||||
import { Component, onWillStart } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class MyAttendances extends Component {
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.user = useService("user");
|
||||
|
||||
this.nextAction = "hr_attendance.hr_attendance_action_my_attendances";
|
||||
this.employee = false;
|
||||
this.checkedIn = false;
|
||||
|
||||
onWillStart(this.onWillStart);
|
||||
}
|
||||
|
||||
async onWillStart() {
|
||||
const result = await this.orm.searchRead(
|
||||
"hr.employee.public",
|
||||
[["user_id", "=", this.user.userId]],
|
||||
["attendance_state", "name", "hours_today"]
|
||||
);
|
||||
this.employee = result[0];
|
||||
if (this.employee) {
|
||||
this.hoursToday = registry.category("formatters").get("float_time")(
|
||||
this.employee.hours_today
|
||||
);
|
||||
this.checkedIn = this.employee.attendance_state === "checked_in";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MyAttendances.template = "hr_attendance.MyAttendances";
|
||||
MyAttendances.components = { CardLayout, CheckInOut };
|
||||
|
||||
registry.category("actions").add("hr_attendance_my_attendances", MyAttendances);
|
||||
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr_attendance.MyAttendances">
|
||||
<CardLayout>
|
||||
<t t-if="employee">
|
||||
<t t-call="hr_attendance.UserBadge">
|
||||
<t t-set="userId" t-value="employee.id"/>
|
||||
<t t-set="userName" t-value="employee.name"/>
|
||||
</t>
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<h1 class="mt-5" t-esc="employee.name"/>
|
||||
<h3><t t-if="!checkedIn">Welcome!</t><t t-else="">Want to check out?</t></h3>
|
||||
<h4 class="mt0 mb0 text-muted" t-if="checkedIn">Today's work hours: <span t-esc="hoursToday"/></h4>
|
||||
</div>
|
||||
|
||||
<CheckInOut checkedIn="checkedIn" employeeId="employee.id" nextAction="nextAction"/>
|
||||
</t>
|
||||
<div t-else="" class="alert alert-warning">
|
||||
<b>Warning</b>: Your user should be linked to an employee to use attendance.<br/> Please contact your administrator.
|
||||
</div>
|
||||
</CardLayout>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,79 @@
|
||||
/* @odoo-module */
|
||||
|
||||
|
||||
import { Component, useState, onWillStart } from "@odoo/owl";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { deserializeDateTime } from "@web/core/l10n/dates";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useDebounced } from "@web/core/utils/timing";
|
||||
const { DateTime } = luxon;
|
||||
|
||||
export class ActivityMenu extends Component {
|
||||
static components = {Dropdown, DropdownItem};
|
||||
static props = [];
|
||||
static template = "hr_attendance.attendance_menu";
|
||||
|
||||
setup() {
|
||||
this.rpc = useService("rpc");
|
||||
this.ui = useState(useService("ui"));
|
||||
this.userService = useService("user");
|
||||
this.employee = false;
|
||||
this.state = useState({
|
||||
checkedIn: false,
|
||||
isDisplayed: false
|
||||
});
|
||||
this.date_formatter = registry.category("formatters").get("float_time")
|
||||
this.onClickSignInOut = useDebounced(this.signInOut, 200, true);
|
||||
onWillStart(this.onWillStart);
|
||||
}
|
||||
|
||||
async onWillStart() {
|
||||
await this.searchReadEmployee();
|
||||
}
|
||||
|
||||
async searchReadEmployee(){
|
||||
const result = await this.rpc("/hr_attendance/attendance_user_data");
|
||||
this.employee = result;
|
||||
if (this.employee.id) {
|
||||
this.hoursToday = this.date_formatter(
|
||||
this.employee.hours_today
|
||||
);
|
||||
this.hoursPreviouslyToday = this.date_formatter(
|
||||
this.employee.hours_previously_today
|
||||
);
|
||||
this.lastAttendanceWorkedHours = this.date_formatter(
|
||||
this.employee.last_attendance_worked_hours
|
||||
);
|
||||
this.lastCheckIn = deserializeDateTime(this.employee.last_check_in).toLocaleString(DateTime.TIME_SIMPLE);
|
||||
this.state.checkedIn = this.employee.attendance_state === "checked_in";
|
||||
this.isFirstAttendance = this.employee.hours_previously_today === 0;
|
||||
this.state.isDisplayed = this.employee.display_systray
|
||||
}
|
||||
}
|
||||
|
||||
async signInOut() {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async ({coords: {latitude, longitude}}) => {
|
||||
await this.rpc("/hr_attendance/systray_check_in_out", {
|
||||
latitude,
|
||||
longitude
|
||||
})
|
||||
await this.searchReadEmployee()
|
||||
},
|
||||
async err => {
|
||||
await this.rpc("/hr_attendance/systray_check_in_out")
|
||||
await this.searchReadEmployee()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const systrayAttendance = {
|
||||
Component: ActivityMenu,
|
||||
};
|
||||
|
||||
registry
|
||||
.category("systray")
|
||||
.add("hr_attendance.attendance_menu", systrayAttendance, { sequence: 100 });
|
||||
@@ -0,0 +1,32 @@
|
||||
.att_container {
|
||||
display: flex;
|
||||
justify-content: normal;
|
||||
align-items: flex-end;
|
||||
min-width: 150px;
|
||||
padding-left: 13px;
|
||||
}
|
||||
|
||||
.o_hour_att {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.hour_label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.span{
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.o_att_menu_container{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.o_hr_attendance_systray_check_in_btn{
|
||||
width: 150px;
|
||||
height: 40px;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr_attendance.attendance_menu" owl="1">
|
||||
<t t-if="this.state.isDisplayed">
|
||||
<Dropdown position="'bottom-end'" beforeOpen.bind="searchReadEmployee" menuClass="`p-0 ${ ui.isSmall ? 'start-0 w-100 mh-100 bottom-0 d-flex flex-column' : ''}`">
|
||||
<div class="d-flex flex-column flex-grow-1">
|
||||
<t t-set-slot="toggler">
|
||||
<i t-attf-class="fa fa-circle text-{{ this.state.checkedIn ? 'success' : 'warning' }}" role="img" aria-label="Attendance"></i>
|
||||
</t>
|
||||
<t t-set-slot="default">
|
||||
<div class="o_att_menu_container m-1">
|
||||
<button t-on-click="() => this.onClickSignInOut()" t-attf-class="o_hr_attendance_systray_check_in_btn btn btn-{{ this.state.checkedIn ? 'warning' : 'success' }} align-self-center">
|
||||
<span class="align-middle text-white" t-if="!this.state.checkedIn">Check IN</span>
|
||||
<i t-attf-class="fa fa-sign-{{ this.state.checkedIn ? 'out' : 'in' }} align-middle"/>
|
||||
<span class="align-middle" t-if="this.state.checkedIn">Check OUT</span>
|
||||
</button>
|
||||
<t t-if="this.state.checkedIn">
|
||||
<div class="att_container">
|
||||
<div class="o_hour_att">
|
||||
<h2 t-esc="this.lastAttendanceWorkedHours"/>
|
||||
</div>
|
||||
<div class="hour_label">
|
||||
<span>Since</span>
|
||||
<span><t t-esc="this.lastCheckIn"/></span>
|
||||
</div>
|
||||
</div>
|
||||
<t t-if="!this.isFirstAttendance">
|
||||
<div class="att_container">
|
||||
<div class="o_hour_att">
|
||||
<h2 t-esc="this.hoursPreviouslyToday"/>
|
||||
</div>
|
||||
<div class="hour_label">
|
||||
<span>Previously</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-if="!this.isFirstAttendance or !this.state.checkedIn">
|
||||
<t t-if="this.state.checkedIn">
|
||||
<div class="att_container" style="border-top: 2px solid #dee2e6;"/>
|
||||
</t>
|
||||
<div class="att_container">
|
||||
<div class="o_hour_att">
|
||||
<h2 t-esc="this.hoursToday"/>
|
||||
</div>
|
||||
<div class="hour_label">
|
||||
<span>Hours</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -2,7 +2,7 @@
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr_attendance.CardLayout">
|
||||
<div class="o_hr_attendance_kiosk_mode_container o_home_menu_background d-flex flex-column align-items-center justify-content-center h-100 text-center position-relative">
|
||||
<div class="o_hr_attendance_kiosk_mode_container o_attendance_background d-flex flex-column align-items-center justify-content-center h-100 text-center position-relative">
|
||||
<span class="o_hr_attendance_kiosk_backdrop position-absolute top-0 start-0 end-0 bottom-0 bg-black-25"/>
|
||||
<div t-attf-class="o_hr_attendance_kiosk_mode flex-grow-1 flex-md-grow-0 card pb-3 px-0 px-lg-5 {{props.kioskModeClasses}}">
|
||||
<div class="card-body d-flex flex-column p-0 p-md-4">
|
||||
|
||||
@@ -14,6 +14,21 @@ export class CheckInOut extends Component {
|
||||
}
|
||||
|
||||
async signInOut() {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
({coords: {latitude, longitude}}) => {
|
||||
this.orm.call("hr.employee", "update_last_position", [
|
||||
[this.props.employeeId],
|
||||
latitude,
|
||||
longitude
|
||||
])
|
||||
},
|
||||
err => {
|
||||
this.orm.call("hr.employee", "update_last_position", [
|
||||
[this.props.employeeId],
|
||||
false,
|
||||
false
|
||||
])
|
||||
})
|
||||
const result = await this.orm.call("hr.employee", "attendance_manual", [
|
||||
[this.props.employeeId],
|
||||
this.props.nextAction,
|
||||
@@ -21,7 +36,7 @@ export class CheckInOut extends Component {
|
||||
if (result.action) {
|
||||
this.actionService.doAction(result.action);
|
||||
} else if (result.warning) {
|
||||
this.notification.add(result.warning, { type: "danger" });
|
||||
this.notification.add(result.warning, {type: "danger"});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import {Component, onWillDestroy} from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { deserializeDateTime } from "@web/core/l10n/dates";
|
||||
|
||||
export class KioskGreetings extends Component {
|
||||
setup() {
|
||||
this.formatDateTime = registry.category("formatters").get("datetime");
|
||||
this.formatFloatTime = registry.category("formatters").get("float_time");
|
||||
this.employeeName = this.props.employeeData.employee_name;
|
||||
this.employeeAvatar = this.props.employeeData.employee_avatar;
|
||||
this.hoursToday = this.formatFloatTime(this.props.employeeData.hours_today);
|
||||
this.attendance = this.props.employeeData.attendance;
|
||||
this.check_in_time = this.formatDateTime(this.attendance.check_in && deserializeDateTime(this.attendance.check_in));
|
||||
this.check_out_time = this.formatDateTime(this.attendance.check_out && deserializeDateTime(this.attendance.check_out));
|
||||
this.kiosk_delay = setTimeout(() => {
|
||||
this.props.kioskReturn(true)
|
||||
}, this.props.employeeData.kiosk_delay)
|
||||
if (this.props.employeeData.display_overtime){
|
||||
this.overtimeToday = this.formatFloatTime(this.props.employeeData.overtime_today);
|
||||
this.totalOvertime = this.formatFloatTime(this.props.employeeData.total_overtime);
|
||||
}
|
||||
onWillDestroy(() => clearTimeout(this.kiosk_delay));
|
||||
}
|
||||
}
|
||||
|
||||
KioskGreetings.template = "hr_attendance.public_kiosk_greetings";
|
||||
KioskGreetings.props = {
|
||||
employeeData : {type: Object},
|
||||
kioskReturn: {type: Function}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="hr_attendance.public_kiosk_greetings">
|
||||
<t t-if="this.attendance">
|
||||
<t t-call="hr_attendance.EmployeeBadge">
|
||||
<t t-set="employeeAvatar" t-value="this.employeeAvatar"/>
|
||||
</t>
|
||||
<div t-if="attendance.check_out" class="flex-grow-1">
|
||||
<h1 class="mt-5">Goodbye <t t-esc="this.employeeName"/>!</h1>
|
||||
<div class="alert alert-info fs-2 mx-3" role="status">
|
||||
Checked out at <b><t t-esc="this.check_out_time"/></b>
|
||||
</div>
|
||||
<div class="alert alert-info fs-2 mx-3" role="status">
|
||||
<b>Hours Today : <t t-esc="this.hoursToday"/></b>
|
||||
<t t-if="this.overtimeToday">
|
||||
<br/>
|
||||
<b>
|
||||
Extra hours today: <span t-esc="this.overtimeToday"/>
|
||||
</b>
|
||||
</t>
|
||||
</div>
|
||||
<t t-if="this.totalOvertime">
|
||||
<div class="alert alert-info h3 mx-3" role="status">
|
||||
Total extra hours: <span t-esc="this.totalOvertime"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div t-else="" class="flex-grow-1">
|
||||
<h1 class="mt-5 mb0">Welcome <t t-esc="this.employeeName"/>!</h1>
|
||||
<div class="alert alert-info fs-2 mx-3" role="status">
|
||||
Checked in at <b><t t-esc="this.check_in_time"/></b>
|
||||
</div>
|
||||
<t t-if="this.hoursToday">
|
||||
<div class="alert alert-info fs-2 mx-3" role="status">
|
||||
<b>
|
||||
Hours Previously Today: <span t-esc="this.hoursToday"/>
|
||||
</b>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<button class="o_hr_attendance_button_dismiss align-self-center btn btn-primary btn-lg px-5 py-3" t-on-click="this.props.kioskReturn">
|
||||
<span class="fs-2" t-if="attendance.check_out">Goodbye</span>
|
||||
<span class="fs-2" t-else="">OK</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="flex-grow-1">
|
||||
<div class="alert alert-warning mt-5 mx-3" role="alert">
|
||||
<h4 class="alert-heading">Invalid request</h4>
|
||||
<p>Please return to the main menu.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<button class="o_hr_attendance_button_dismiss btn btn-primary btn-lg fs-2 px-5 py-3" t-on-click="this.props.kioskReturn">
|
||||
<i class="oi oi-chevron-left me-2"/>
|
||||
<span class="fs-2">Go back</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,16 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { BarcodeScanner } from "@barcodes/components/barcode_scanner";
|
||||
|
||||
export class KioskBarcodeScanner extends BarcodeScanner {
|
||||
get facingMode() {
|
||||
if (this.props.barcodeSource == "front") {
|
||||
return "user";
|
||||
}
|
||||
return super.facingMode;
|
||||
}
|
||||
}
|
||||
KioskBarcodeScanner.props = {
|
||||
...BarcodeScanner.props,
|
||||
barcodeSource: String,
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import {Component, useState} from "@odoo/owl";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
|
||||
export class KioskManualSelection extends Component {
|
||||
setup() {
|
||||
this.state = useState({
|
||||
displayedEmployees : this.props.employees,
|
||||
searchInput: ""
|
||||
});
|
||||
this.displayedEmployees = this.props.employees
|
||||
}
|
||||
onDepartementClick(dep_id){
|
||||
if (dep_id){
|
||||
this.state.displayedEmployees = this.props.employees.filter(item => item.department.id === dep_id)
|
||||
}else{
|
||||
this.state.displayedEmployees = this.props.employees
|
||||
}
|
||||
}
|
||||
|
||||
onSearchInput(ev) {
|
||||
const searchInput = ev.target.value;
|
||||
if (searchInput.length){
|
||||
this.state.displayedEmployees = this.props.employees.filter(item => item.name.toLowerCase().includes(searchInput.toLowerCase()))
|
||||
}else{
|
||||
this.state.displayedEmployees = this.props.employees
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
KioskManualSelection.template = "hr_attendance.public_kiosk_manual_selection";
|
||||
KioskManualSelection.components = {
|
||||
Dropdown,
|
||||
DropdownItem
|
||||
}
|
||||
KioskManualSelection.props = {
|
||||
employees : {type : Array},
|
||||
displayBackButton : {type : Boolean},
|
||||
departments: {type : Array},
|
||||
onSelectEmployee : {type : Function}
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="hr_attendance.employeeListElement">
|
||||
<div class="d-flex position-relative py-2 overflow-visible o_treeEntry">
|
||||
<div class="o_media_left position-relative">
|
||||
<div class="o_media_object d-block rounded border border-info">
|
||||
<img t-attf-src="data:image/png;base64,{{avatar}}" height="80" width="80"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-grow-1 align-items-center justify-content-between position-relative px-3">
|
||||
<div class="d-flex flex-column align-items-start">
|
||||
<h5 class="o_media_heading m-0"><t t-esc="employeeName"/></h5>
|
||||
<small class="text-muted fw-bold"><t t-esc="jobTitle"/></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="hr_attendance.public_kiosk_manual_selection">
|
||||
<t t-if="!this.props.displayBackButton">
|
||||
<button t-on-click="() => this.props.onClickBack()" class="o_hr_attendance_back_button o_hr_attendance_back_button_md btn btn-secondary d-none d-md-inline-flex align-items-center position-absolute top-0 start-0 rounded-circle">
|
||||
<i class="oi fa-2x fa-fw oi-chevron-left me-1" role="img" aria-label="Go back" title="Go back"/>
|
||||
</button>
|
||||
</t>
|
||||
<div class="o_kanban_view o_view_controller o_action">
|
||||
<div class="o_control_panel d-flex flex-column gap-3 gap-lg-1 px-3 pt-2 pb-3" style="top: 0px;">
|
||||
<div class="o_control_panel_main d-flex flex-wrap flex-lg-nowrap justify-content-between align-items-lg-start gap-3 flex-grow-1">
|
||||
<div class="o_cp_searchview d-flex input-group">
|
||||
<div class="o_searchview form-control d-print-contents d-flex align-items-center py-1 border-end-0">
|
||||
<i class="o_searchview_icon d-print-none oi oi-search me-2" aria-label="Search..." title="Search..."/>
|
||||
<div class="o_searchview_input_container d-flex flex-grow-1 flex-wrap gap-1" style="position: relative;">
|
||||
<input t-on-input="onSearchInput" type="text" class="o_searchview_input d-print-none flex-grow-1 w-auto border-0" placeholder="Search..."/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_content o_component_with_search_panel">
|
||||
<div class="o_search_panel flex-grow-0 flex-shrink-0 h-100 pb-5 bg-view overflow-auto pe-1 ps-3">
|
||||
<section class="o_search_panel_section o_search_panel_category">
|
||||
<header class="o_search_panel_section_header pt-4 pb-2 text-uppercase cursor-default">
|
||||
<i class="fa fa-users o_search_panel_section_icon text-odoo me-2"/>
|
||||
<b>Department</b>
|
||||
</header>
|
||||
<ul class="list-group d-block o_search_panel_field">
|
||||
<li class="o_search_panel_category_value list-group-item py-1 cursor-pointer border-0 pe-0 ps-0">
|
||||
<header class="list-group-item list-group-item-action d-flex align-items-center px-0 py-lg-0 border-0 text-900 fw-bold">
|
||||
<div t-on-click="() => this.onDepartementClick(false)" class="o_search_panel_label d-flex align-items-center overflow-hidden w-100 cursor-pointer mb-0 o_with_counters">
|
||||
<span class="o_search_panel_label_title text-truncate">All</span>
|
||||
</div>
|
||||
</header>
|
||||
</li>
|
||||
<t t-foreach="this.props.departments" t-as="dep" t-key="dep.id">
|
||||
<li class="o_search_panel_category_value list-group-item py-1 cursor-pointer border-0 pe-0 ps-0">
|
||||
<header class="list-group-item list-group-item-action d-flex align-items-center px-0 py-lg-0 border-0">
|
||||
<div t-on-click="() => this.onDepartementClick(dep.id)" class="o_search_panel_label d-flex align-items-center overflow-hidden w-100 cursor-pointer mb-0 o_with_counters">
|
||||
<span class="o_search_panel_label_title text-truncate"><t t-esc="dep.name"/></span>
|
||||
</div>
|
||||
<small class="o_search_panel_counter text-muted mx-2 fw-bold"><t t-esc="dep.count"/></small>
|
||||
</header>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<div class="o_kanban_renderer o_renderer d-flex o_kanban_ungrouped align-content-start flex-wrap justify-content-start" style="width:1050px;height:500px;overflow-y:scroll;">
|
||||
<t t-foreach="this.state.displayedEmployees" t-as="employee" t-key="employee.id">
|
||||
<div t-on-click="() => this.props.onSelectEmployee(employee.id)" role="article" class="o_kanban_record d-flex oe_kanban_global_click flex-md-shrink-1 flex-shrink-0">
|
||||
<div class="oe_kanban_global_click">
|
||||
<div class="o_kanban_image">
|
||||
<img alt="Employee" loading="lazy" t-attf-src="data:image/png;base64,{{employee.avatar}}"/>
|
||||
</div>
|
||||
<div class="oe_kanban_details">
|
||||
<div id="textbox">
|
||||
<strong><span><t t-esc="employee.name"/></span></strong>
|
||||
</div>
|
||||
<ul>
|
||||
<li><span><t t-esc="employee.job"/></span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,73 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, onWillStart, useState, onWillDestroy } from "@odoo/owl";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
export class KioskPinCode extends Component {
|
||||
setup() {
|
||||
this.padButtons = [
|
||||
...Array.from({ length: 9 }, (_, i) => [i + 1]), // [[1], ..., [9]]
|
||||
["C", "btn-warning"],
|
||||
[0],
|
||||
["OK", "btn-primary"],
|
||||
];
|
||||
this.state = useState({
|
||||
codePin: "",
|
||||
});
|
||||
this.lockPad = false;
|
||||
this.checkedIn = this.props.employeeData.attendance_state === 'checked_in';
|
||||
|
||||
const onKeyDown = async (ev) => {
|
||||
const allowedKeys = [...Array(10).keys()].reduce((acc, value) => { // { from '0': '0' ... to '9': '9' }
|
||||
acc[value] = value;
|
||||
return acc;
|
||||
}, {
|
||||
'Delete': 'C',
|
||||
'Enter': 'OK',
|
||||
'Backspace': null,
|
||||
});
|
||||
const key = ev.key;
|
||||
|
||||
if (!Object.keys(allowedKeys).includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (allowedKeys[key]) {
|
||||
await this.onClickPadButton(allowedKeys[key]);
|
||||
}
|
||||
else {
|
||||
this.state.codePin = this.state.codePin.substring(0, this.state.codePin.length - 1);
|
||||
}
|
||||
}
|
||||
browser.addEventListener('keydown', onKeyDown);
|
||||
onWillStart(() => browser.addEventListener('keydown', onKeyDown))
|
||||
onWillDestroy(() => browser.removeEventListener('keydown', onKeyDown));
|
||||
}
|
||||
|
||||
async onClickPadButton(value) {
|
||||
if (this.lockPad) {
|
||||
return;
|
||||
}
|
||||
if (value === "C") {
|
||||
this.state.codePin = "";
|
||||
} else if (value === "OK") {
|
||||
this.lockPad = true;
|
||||
await this.props.onPinConfirm(this.props.employeeData.id, this.state.codePin)
|
||||
this.state.codePin = "";
|
||||
this.lockPad = false;
|
||||
} else {
|
||||
this.state.codePin += value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
KioskPinCode.props = {
|
||||
employeeData : {type: Object},
|
||||
onClickBack: {type: Function},
|
||||
onPinConfirm: {type: Function}
|
||||
}
|
||||
|
||||
KioskPinCode.template = "hr_attendance.KioskPinConfirm";
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr_attendance.EmployeeBadge">
|
||||
<div class="o_hr_attendance_user_badge o_attendance_background d-flex align-items-end justify-content-center flex-grow-1 pt-5 pt-md-4 bg-odoo">
|
||||
<img class="o_hr_attendance_employee_badge img rounded-circle" t-attf-src="data:image/png;base64,{{employeeAvatar}}" height="80"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="hr_attendance.KioskPinConfirm">
|
||||
<button class="o_hr_attendance_back_button btn btn-block btn-secondary btn-lg d-block d-md-none py-5">
|
||||
<i class="oi oi-chevron-left me-2"/> Go back
|
||||
</button>
|
||||
<t t-call="hr_attendance.EmployeeBadge">
|
||||
<t t-set="employeeAvatar" t-value="this.props.employeeData.employee_avatar"/>
|
||||
</t>
|
||||
<button t-on-click="() => this.props.onClickBack()" class="o_hr_attendance_back_button o_hr_attendance_back_button_md btn btn-secondary d-none d-md-inline-flex align-items-center position-absolute top-0 start-0 rounded-circle">
|
||||
<i class="oi fa-2x fa-fw oi-chevron-left me-1" role="img" aria-label="Go back" title="Go back"/>
|
||||
</button>
|
||||
|
||||
<div t-if="this.props.employeeData" class="flex-grow-1">
|
||||
<h1 class="mt-5 mb8" t-esc="this.props.employeeData.employee_name"/>
|
||||
<h3 class="mt8 mb24"><t t-if="!checkedIn">Welcome!</t><t t-else="">Want to check out?</t></h3>
|
||||
<h3 class="mt-4 mb0 text-muted">Please enter your PIN to <b t-if="checkedIn">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 g-0" >
|
||||
<input t-att-value="state.codePin" class="o_hr_attendance_PINbox col-12 mb8 mt8 border-0 bg-white fs-1 text-center" type="password" disabled="true"/>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<t t-foreach="padButtons" t-as="btn" t-key="btn[0]">
|
||||
<div class="col-4 p-1">
|
||||
<a href="#" t-on-click="() => this.onClickPadButton(btn[0])" t-attf-class="o_hr_attendance_PINbox_button btn {{btn[1]? btn[1] : 'btn-secondary border'}} btn-block btn-lg d-flex align-items-center justify-content-center">
|
||||
<t t-esc="btn[0]"/>
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div t-else="" class="alert alert-danger mx-3" role="alert">
|
||||
<h4 class="alert-heading">Error: could not find corresponding employee.</h4>
|
||||
<p>Please return to the main menu.</p>
|
||||
</div>
|
||||
<a role="button" class="oe_attendance_sign_in_out" aria-label="Sign out" title="Sign out"/>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
+24
@@ -23,3 +23,27 @@
|
||||
aspect-ratio: 1.8;
|
||||
}
|
||||
}
|
||||
|
||||
.o_hr_attendance_employee_badge{
|
||||
margin-bottom: -48px !important;
|
||||
}
|
||||
|
||||
.o_hr_attendance_kiosk_body{
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.o_hr_kiosk_url_media{
|
||||
a{
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Shared with web client and login screen
|
||||
.o_attendance_background{
|
||||
background: {
|
||||
size: cover;
|
||||
attachment: fixed;
|
||||
color: var(--homeMenu-bg-color, #{$o-gray-200});
|
||||
image: var(--homeMenu-bg-image, url("/hr_attendance/static/img/background-light.svg"));
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { graphView } from "@web/views/graph/graph_view";
|
||||
import { pivotView } from "@web/views/pivot/pivot_view";
|
||||
import { useComponent } from "@odoo/owl";
|
||||
|
||||
const viewRegistry = registry.category("views");
|
||||
|
||||
/**
|
||||
* Open the hr.attendance instead of the report list view.
|
||||
*/
|
||||
function useOpenView() {
|
||||
const action = useService('action');
|
||||
const component = useComponent();
|
||||
|
||||
return (domain, views, context) => {
|
||||
action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
name: component.model.metaData.title,
|
||||
res_model: 'hr.attendance',
|
||||
views: [[false, "list"], [false, "form"]],
|
||||
view_mode: "list",
|
||||
target: "current",
|
||||
context,
|
||||
domain,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export class AttendanceReportGraphRenderer extends graphView.Renderer {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.openView = useOpenView();
|
||||
}
|
||||
}
|
||||
|
||||
viewRegistry.add("attendance_report_graph", {
|
||||
...graphView,
|
||||
Renderer: AttendanceReportGraphRenderer
|
||||
});
|
||||
|
||||
export class AttendanceReportPivotRenderer extends pivotView.Renderer {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.openView = useOpenView();
|
||||
}
|
||||
}
|
||||
|
||||
viewRegistry.add("attendance_report_pivot", {
|
||||
...pivotView,
|
||||
Renderer: AttendanceReportPivotRenderer
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import {App, whenReady, Component, useState} from "@odoo/owl";
|
||||
import { CardLayout } from "@hr_attendance/components/card_layout/card_layout";
|
||||
import { KioskManualSelection } from "@hr_attendance/components/manual_selection/manual_selection";
|
||||
import { makeEnv, startServices } from "@web/env";
|
||||
import { templates } from "@web/core/assets";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { MainComponentsContainer } from "@web/core/main_components_container";
|
||||
import { useService, useBus } from "@web/core/utils/hooks";
|
||||
import { url } from "@web/core/utils/urls";
|
||||
import {KioskGreetings} from "@hr_attendance/components/greetings/greetings";
|
||||
import {KioskPinCode} from "@hr_attendance/components/pin_code/pin_code";
|
||||
import {KioskBarcodeScanner} from "@hr_attendance/components/kiosk_barcode/kiosk_barcode";
|
||||
|
||||
class kioskAttendanceApp extends Component{
|
||||
static props = [];
|
||||
static components = {
|
||||
KioskBarcodeScanner,
|
||||
CardLayout,
|
||||
KioskManualSelection,
|
||||
KioskGreetings,
|
||||
KioskPinCode,
|
||||
MainComponentsContainer,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.rpc = useService("rpc");
|
||||
this.barcode = useService("barcode");
|
||||
this.notification = useService("notification");
|
||||
this.companyImageUrl = url("/web/image", {
|
||||
model: "res.company",
|
||||
id: this.props.companyId,
|
||||
field: "logo",
|
||||
});
|
||||
this.lockScanner = false;
|
||||
if (this.props.kioskMode !== 'manual'){
|
||||
useBus(this.barcode.bus, "barcode_scanned", (ev) => this.onBarcodeScanned(ev.detail.barcode));
|
||||
this.state = useState({active_display: "main"});
|
||||
this.manualKioskMode = false
|
||||
}else{
|
||||
this.manualKioskMode = true
|
||||
this.state = useState({active_display: "manual"});
|
||||
}
|
||||
}
|
||||
|
||||
switchDisplay(screen) {
|
||||
const displays = ["main", "greet", "manual", "pin"]
|
||||
if (displays.includes(screen)){
|
||||
this.state.active_display = screen;
|
||||
}else{
|
||||
this.state.active_display = "main";
|
||||
}
|
||||
}
|
||||
|
||||
async kioskConfirm(employeeId){
|
||||
const employee = await this.rpc('attendance_employee_data',
|
||||
{
|
||||
'token': this.props.token,
|
||||
'employee_id': employeeId
|
||||
})
|
||||
if (employee && employee.employee_name){
|
||||
if (employee.use_pin){
|
||||
this.employeeData = employee
|
||||
this.switchDisplay('pin')
|
||||
}else{
|
||||
await this.onManualSelection(employeeId, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kioskReturn() {
|
||||
if (this.props.kioskMode !== 'manual'){
|
||||
this.switchDisplay('main')
|
||||
}else{
|
||||
this.switchDisplay('manual')
|
||||
}
|
||||
}
|
||||
|
||||
displayNotification(text){
|
||||
this.notification.add(text, { type: "danger" });
|
||||
}
|
||||
|
||||
async onManualSelection(employeeId, enteredPin){
|
||||
const result = await this.rpc('manual_selection',
|
||||
{
|
||||
'token': this.props.token,
|
||||
'employee_id': employeeId,
|
||||
'pin_code': enteredPin
|
||||
})
|
||||
if (result && result.attendance) {
|
||||
this.employeeData = result
|
||||
this.switchDisplay('greet')
|
||||
}else{
|
||||
if (enteredPin){
|
||||
this.displayNotification(_t("Wrong Pin"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onBarcodeScanned(barcode){
|
||||
if (this.lockScanner || this.state.active_display !== 'main') {
|
||||
return;
|
||||
}
|
||||
this.lockScanner = true;
|
||||
const result = await this.rpc('attendance_barcode_scanned',
|
||||
{
|
||||
'barcode': barcode,
|
||||
'token': this.props.token
|
||||
})
|
||||
if (result && result.employee_name) {
|
||||
this.employeeData = result
|
||||
this.switchDisplay('greet')
|
||||
}else{
|
||||
this.displayNotification(_t("No employee corresponding to Badge ID '%(barcode)s.'", { barcode }))
|
||||
}
|
||||
this.lockScanner = false
|
||||
}
|
||||
}
|
||||
|
||||
kioskAttendanceApp.template = "hr_attendance.public_kiosk_app";
|
||||
|
||||
export async function createPublicKioskAttendance(document, kiosk_backend_info) {
|
||||
await whenReady();
|
||||
const env = makeEnv();
|
||||
await startServices(env);
|
||||
const app = new App(kioskAttendanceApp, {
|
||||
templates,
|
||||
env: env,
|
||||
props:
|
||||
{
|
||||
token : kiosk_backend_info.token,
|
||||
companyId: kiosk_backend_info.company_id,
|
||||
companyName: kiosk_backend_info.company_name,
|
||||
employees: kiosk_backend_info.employees,
|
||||
departments: kiosk_backend_info.departments,
|
||||
kioskMode: kiosk_backend_info.kiosk_mode,
|
||||
barcodeSource: kiosk_backend_info.employees,
|
||||
},
|
||||
dev: env.debug,
|
||||
translateFn: _t,
|
||||
translatableAttributes: ["data-tooltip"],
|
||||
});
|
||||
return app.mount(document.body);
|
||||
}
|
||||
export default { kioskAttendanceApp, createPublicKioskAttendance };
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr_attendance.companyHeader">
|
||||
<h2 class="mb-2"><small>Welcome to</small></h2>
|
||||
<img t-att-src="companyImageUrl" alt="Company Logo" class="o_hr_attendance_kiosk_company_image align-self-center img img-fluid mb-3" width="200"/>
|
||||
</t>
|
||||
|
||||
<t t-name="hr_attendance.public_kiosk_app">
|
||||
<MainComponentsContainer/>
|
||||
<CardLayout>
|
||||
<t t-if="this.state.active_display === 'main'">
|
||||
<t t-call="hr_attendance.companyHeader">
|
||||
<t t-set="companyImageUrl" t-value="companyImageUrl"/>
|
||||
<t t-set="companyName" t-value="this.props.companyName"/>
|
||||
</t>
|
||||
<t t-if="this.props.kioskMode !== 'manual'">
|
||||
<div class="col-md-5 mt-5 mb-5 mb-md-0 align-self-center">
|
||||
<KioskBarcodeScanner barcodeSource="this.props.barcodeSource" onBarcodeScanned="(ev) => this.onBarcodeScanned(ev)"/>
|
||||
<h6 class="mt-2 text-muted">Scan your badge</h6>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="this.props.kioskMode !== 'barcode'">
|
||||
<div class="mt-5 align-self-center">
|
||||
<button t-on-click="() => this.switchDisplay('manual')" class="o_hr_attendance_button_employees btn btn-link">
|
||||
Identify Manually
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-if="this.state.active_display === 'manual'">
|
||||
<t t-call="hr_attendance.companyHeader">
|
||||
<t t-set="companyImageUrl" t-value="companyImageUrl"/>
|
||||
<t t-set="companyName" t-value="this.props.companyName"/>
|
||||
</t>
|
||||
<KioskManualSelection employees="this.props.employees" displayBackButton="this.manualKioskMode" departments="this.props.departments" onSelectEmployee="(e) => this.kioskConfirm(e)" onClickBack="() => this.kioskReturn()"/>
|
||||
</t>
|
||||
<t t-if="this.state.active_display === 'greet'">
|
||||
<KioskGreetings employeeData="this.employeeData" kioskReturn="() => this.kioskReturn(true)"/>
|
||||
</t>
|
||||
<t t-if="this.state.active_display === 'pin'">
|
||||
<KioskPinCode employeeData="this.employeeData" onPinConfirm="(id, pin) => this.onManualSelection(id, pin)" onClickBack="() => this.kioskReturn()"/>
|
||||
</t>
|
||||
</CardLayout>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -1,229 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { barcodeService } from "@barcodes/barcode_service";
|
||||
import { getFixture, makeDeferred, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { session } from "@web/session";
|
||||
import { companyService } from "@web/webclient/company_service";
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
|
||||
let serverData;
|
||||
let target;
|
||||
|
||||
QUnit.module("Client Actions", (hooks) => {
|
||||
hooks.beforeEach(() => {
|
||||
// serverData = getActionManagerServerData();
|
||||
serverData = {
|
||||
models: {
|
||||
"hr.employee.public": {
|
||||
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" },
|
||||
overtime: { string: "Overtime", 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" },
|
||||
attendance_kiosk_mode: { type: "char" },
|
||||
attendance_barcode_source: { type: "char" },
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Company A",
|
||||
attendance_kiosk_mode: "barcode_manual",
|
||||
attendance_barcode_source: "front",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
"hr_attendance.hr_attendance_action_kiosk_mode": {
|
||||
type: "ir.actions.client",
|
||||
tag: "hr_attendance_kiosk_mode",
|
||||
},
|
||||
},
|
||||
};
|
||||
target = getFixture();
|
||||
|
||||
serviceRegistry.add("barcode", barcodeService);
|
||||
serviceRegistry.add("company", companyService);
|
||||
});
|
||||
|
||||
QUnit.test("My attendances: simple rendering", async function (assert) {
|
||||
patchWithCleanup(session, {
|
||||
uid: 1,
|
||||
});
|
||||
|
||||
const webClient = await createWebClient({ serverData });
|
||||
await doAction(webClient, {
|
||||
type: "ir.actions.client",
|
||||
tag: "hr_attendance_my_attendances",
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
target.querySelector(".o_hr_attendance_kiosk_mode h1").textContent,
|
||||
"Employee A",
|
||||
"should have rendered the client action without crashing"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Kiosk Mode: simple rendering", async function (assert) {
|
||||
patchWithCleanup(session, {
|
||||
uid: 1,
|
||||
user_companies: {
|
||||
current_company: 1,
|
||||
allowed_companies: {
|
||||
1: { id: 1 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let rpcCount = 0;
|
||||
const mockRPC = async (route, args) => {
|
||||
if (args.method === "attendance_scan" && args.model === "hr.employee") {
|
||||
rpcCount++;
|
||||
return serverData.models["hr.employee.public"].records.find((r) => r.id === args.args[0]);
|
||||
}
|
||||
};
|
||||
const webClient = await createWebClient({ serverData, mockRPC });
|
||||
await doAction(webClient, {
|
||||
type: "ir.actions.client",
|
||||
tag: "hr_attendance_kiosk_mode",
|
||||
});
|
||||
|
||||
const { barcode } = webClient.env.services;
|
||||
barcode.bus.trigger("barcode_scanned", { barcode: 1 });
|
||||
barcode.bus.trigger("barcode_scanned", { barcode: 1 });
|
||||
assert.strictEqual(rpcCount, 1, "RPC call should have been done only once.");
|
||||
|
||||
barcode.bus.trigger("barcode_scanned", { barcode: 2 });
|
||||
assert.strictEqual(rpcCount, 1, "RPC call should have been done only once.");
|
||||
});
|
||||
|
||||
QUnit.test("Attendance Greeting Message Test", async function (assert) {
|
||||
patchWithCleanup(session, {
|
||||
uid: 1,
|
||||
user_companies: {
|
||||
current_company: 1,
|
||||
allowed_companies: {
|
||||
1: { id: 1 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let rpcCount = 0;
|
||||
let def;
|
||||
|
||||
const actions = [
|
||||
{
|
||||
type: "ir.actions.client",
|
||||
tag: "hr_attendance_greeting_message",
|
||||
attendance: {
|
||||
check_in: "2018-09-20 13:41:13",
|
||||
employee_id: [1],
|
||||
},
|
||||
next_action: "hr_attendance.hr_attendance_action_kiosk_mode",
|
||||
},
|
||||
{
|
||||
type: "ir.actions.client",
|
||||
tag: "hr_attendance_kiosk_mode",
|
||||
},
|
||||
{
|
||||
type: "ir.actions.client",
|
||||
tag: "hr_attendance_greeting_message",
|
||||
attendance: {
|
||||
check_in: "2018-09-20 13:41:13",
|
||||
check_out: "2018-09-20 14:41:13",
|
||||
employee_id: [1],
|
||||
},
|
||||
next_action: "hr_attendance.hr_attendance_action_kiosk_mode",
|
||||
},
|
||||
];
|
||||
|
||||
const mockRPC = async (route, args) => {
|
||||
if (args.method === "attendance_scan" && args.model === "hr.employee") {
|
||||
await def;
|
||||
return {
|
||||
action: actions[rpcCount++],
|
||||
};
|
||||
}
|
||||
};
|
||||
const webClient = await createWebClient({ serverData, mockRPC });
|
||||
await doAction(webClient, {
|
||||
type: "ir.actions.client",
|
||||
tag: "hr_attendance_kiosk_mode",
|
||||
});
|
||||
assert.containsOnce(target, ".o_hr_attendance_kiosk_welcome_row");
|
||||
|
||||
const { barcode } = webClient.env.services;
|
||||
barcode.bus.trigger("barcode_scanned", { barcode: 1 });
|
||||
await nextTick();
|
||||
assert.strictEqual(rpcCount, 1, "RPC call should have been done only once.");
|
||||
assert.containsNone(target, ".o_hr_attendance_kiosk_welcome_row");
|
||||
assert.strictEqual(
|
||||
target.querySelector(".o_hr_attendance_message_message").textContent,
|
||||
"Good afternoon"
|
||||
);
|
||||
assert.strictEqual(
|
||||
target
|
||||
.querySelector(".o_hr_attendance_kiosk_mode div[role='status']")
|
||||
.textContent.trim(),
|
||||
"Checked in at 09/20/2018 14:41:13"
|
||||
);
|
||||
|
||||
def = makeDeferred();
|
||||
barcode.bus.trigger("barcode_scanned", { barcode: 1 });
|
||||
barcode.bus.trigger("barcode_scanned", { barcode: 1 });
|
||||
def.resolve();
|
||||
await nextTick();
|
||||
assert.strictEqual(rpcCount, 2, "RPC call should have been done only once.");
|
||||
assert.containsOnce(target, ".o_hr_attendance_kiosk_mode");
|
||||
|
||||
barcode.bus.trigger("barcode_scanned", { barcode: 1 });
|
||||
await nextTick();
|
||||
assert.strictEqual(rpcCount, 3, "RPC call should have been done only once.");
|
||||
assert.containsNone(target, ".o_hr_attendance_kiosk_welcome_row");
|
||||
assert.strictEqual(
|
||||
target.querySelector(".o_hr_attendance_message_message").textContent,
|
||||
"Have a good afternoon"
|
||||
);
|
||||
assert.strictEqual(
|
||||
target
|
||||
.querySelector(".o_hr_attendance_kiosk_mode div[role='status']")
|
||||
.textContent.trim(),
|
||||
"Checked out at 09/20/2018 15:41:13"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { MockServer } from "@web/../tests/helpers/mock_server";
|
||||
|
||||
patch(MockServer.prototype, {
|
||||
/**
|
||||
* Simulate the initialization of the attendance systray data
|
||||
* @override
|
||||
*/
|
||||
async _performRPC(route, args) {
|
||||
if (route === "/hr_attendance/attendance_user_data") {
|
||||
return Promise.resolve({
|
||||
"id": 1,
|
||||
"employee_name": "Mitchell Admin",
|
||||
"employee_avatar": false,
|
||||
"hours_today": 0.0019,
|
||||
"total_overtime": 0,
|
||||
"last_attendance_worked_hours": 0.0019,
|
||||
"last_check_in": "2023-10-02 07:54:31",
|
||||
"attendance_state": "checked_out",
|
||||
"hours_previously_today": 0,
|
||||
"kiosk_delay": 10000,
|
||||
"attendance": {
|
||||
"check_in": "2023-10-02 07:54:31",
|
||||
"check_out": "2023-10-02 07:54:38"
|
||||
},
|
||||
"overtime_today": 0,
|
||||
"use_pin": false
|
||||
})
|
||||
}
|
||||
return super._performRPC(...arguments);
|
||||
},
|
||||
});
|
||||
@@ -18,7 +18,7 @@ class TestHrAttendanceOvertime(TransactionCase):
|
||||
'overtime_company_threshold': 10,
|
||||
'overtime_employee_threshold': 10,
|
||||
})
|
||||
cls.user = new_test_user(cls.env, login='fru', groups='base.group_user,hr_attendance.group_hr_attendance', company_id=cls.company.id).with_company(cls.company)
|
||||
cls.user = new_test_user(cls.env, login='fru', groups='base.group_user,hr_attendance.group_hr_attendance_manager', company_id=cls.company.id).with_company(cls.company)
|
||||
cls.employee = cls.env['hr.employee'].create({
|
||||
'name': "Marie-Edouard De La Court",
|
||||
'user_id': cls.user.id,
|
||||
@@ -137,7 +137,12 @@ class TestHrAttendanceOvertime(TransactionCase):
|
||||
self.assertEqual(self.employee.total_overtime, 2)
|
||||
self.assertEqual(self.other_employee.total_overtime, 0)
|
||||
|
||||
attendance.employee_id = self.other_employee.id
|
||||
Attendance.create({
|
||||
'employee_id': self.other_employee.id,
|
||||
'check_in': datetime(2021, 1, 4, 7, 0),
|
||||
'check_out': datetime(2021, 1, 4, 18, 0)
|
||||
})
|
||||
attendance.unlink()
|
||||
self.assertEqual(self.other_employee.total_overtime, 2)
|
||||
self.assertEqual(self.employee.total_overtime, 0)
|
||||
|
||||
@@ -284,3 +289,68 @@ class TestHrAttendanceOvertime(TransactionCase):
|
||||
attendance.check_in = datetime(2021, 1, 4, 9, 0)
|
||||
attendance.check_out = datetime(2021, 1, 4, 18, 0)
|
||||
self.assertEqual(self.employee.total_overtime, 0, 'There should be no overtime since the employee worked through the lunch period.')
|
||||
|
||||
def test_overtime_hours_inside_attendance(self):
|
||||
# 1 Attendance case
|
||||
attendance = self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2023, 1, 2, 8, 0),
|
||||
'check_out': datetime(2023, 1, 2, 21, 0)
|
||||
})
|
||||
|
||||
# 8:00 -> 21:00 should contain 4 hours of overtime
|
||||
self.assertAlmostEqual(attendance.overtime_hours, 4, 2)
|
||||
|
||||
# Total overtime for that day : 4 hours
|
||||
overtime_1 = self.env['hr.attendance.overtime'].search([('employee_id', '=', self.employee.id),
|
||||
('date', '=', datetime(2023, 1, 2))])
|
||||
self.assertAlmostEqual(overtime_1.duration, 4, 2)
|
||||
|
||||
# Multi attendance case
|
||||
|
||||
m_attendance_1 = self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2023, 1, 3, 8, 0),
|
||||
'check_out': datetime(2023, 1, 3, 19, 0)
|
||||
})
|
||||
# 8:00 -> 19:00 should contain 2 hours of overtime
|
||||
self.assertAlmostEqual(m_attendance_1.overtime_hours, 2, 2)
|
||||
|
||||
m_attendance_2 = self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2023, 1, 3, 19, 0),
|
||||
'check_out': datetime(2023, 1, 3, 20, 0)
|
||||
})
|
||||
# 19:00 -> 20:00 should contain 1 hour of overtime
|
||||
self.assertAlmostEqual(m_attendance_2.overtime_hours, 1, 2)
|
||||
|
||||
m_attendance_3 = self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2023, 1, 3, 21, 0),
|
||||
'check_out': datetime(2023, 1, 3, 23, 0)
|
||||
})
|
||||
# 21:00 -> 23:00 should contain 2 hours of overtime
|
||||
self.assertAlmostEqual(m_attendance_3.overtime_hours, 2, 2)
|
||||
|
||||
overtime_2 = self.env['hr.attendance.overtime'].search([('employee_id', '=', self.employee.id),
|
||||
('date', '=', datetime(2023, 1, 3))])
|
||||
# Total overtime for that day : 5 hours
|
||||
self.assertEqual(overtime_2.duration, 5)
|
||||
|
||||
# Attendance Modification case
|
||||
|
||||
m_attendance_3.write({
|
||||
'check_out': datetime(2023, 1, 3, 22, 30)
|
||||
})
|
||||
|
||||
self.assertEqual(m_attendance_3.overtime_hours, 1.5)
|
||||
|
||||
# Deleting previous attendances should update correctly the overtime hours in other attendances
|
||||
m_attendance_2.unlink()
|
||||
m_attendance_1.write({
|
||||
'check_out': datetime(2023, 1, 3, 17, 0)
|
||||
})
|
||||
m_attendance_3.write({
|
||||
'check_out': datetime(2023, 1, 3, 21, 30)
|
||||
})
|
||||
self.assertEqual(m_attendance_3.overtime_hours, 0.5)
|
||||
|
||||
@@ -16,7 +16,7 @@ class TestHrAttendance(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestHrAttendance, cls).setUpClass()
|
||||
cls.user = new_test_user(cls.env, login='fru', groups='base.group_user,hr_attendance.group_hr_attendance_use_pin')
|
||||
cls.user = new_test_user(cls.env, login='fru', groups='base.group_user')
|
||||
cls.user_no_pin = new_test_user(cls.env, login='gru', groups='base.group_user')
|
||||
cls.test_employee = cls.env['hr.employee'].create({
|
||||
'name': "François Russie",
|
||||
@@ -41,57 +41,6 @@ class TestHrAttendance(TransactionCase):
|
||||
self.test_employee._attendance_action_change()
|
||||
assert self.test_employee.attendance_state == 'checked_out'
|
||||
|
||||
def test_checkin_self_without_pin(self):
|
||||
""" Employee can check in/out without pin with his own account """
|
||||
employee = self.test_employee.with_user(self.user)
|
||||
employee.with_user(self.user).attendance_manual({}, entered_pin=None)
|
||||
self.assertEqual(employee.attendance_state, 'checked_in', "He should be able to check in without pin")
|
||||
employee.attendance_manual({}, entered_pin=None)
|
||||
self.assertEqual(employee.attendance_state, 'checked_out', "He should be able to check out without pin")
|
||||
|
||||
def test_checkin_self_with_pin(self):
|
||||
""" Employee can check in/out with pin with his own account """
|
||||
employee = self.test_employee.with_user(self.user)
|
||||
employee.attendance_manual({}, entered_pin='1234')
|
||||
self.assertEqual(employee.attendance_state, 'checked_in', "He should be able to check in with his pin")
|
||||
employee.attendance_manual({}, entered_pin='1234')
|
||||
self.assertEqual(employee.attendance_state, 'checked_out', "He should be able to check out with his pin")
|
||||
|
||||
def test_checkin_self_wrong_pin(self):
|
||||
""" Employee cannot check in/out with wrong pin with his own account """
|
||||
employee = self.test_employee.with_user(self.user)
|
||||
action = employee.attendance_manual({}, entered_pin='9999')
|
||||
self.assertNotEqual(employee.attendance_state, 'checked_in', "He should not be able to check in with a wrong pin")
|
||||
self.assertTrue(action.get('warning'))
|
||||
|
||||
def test_checkin_kiosk_with_pin(self):
|
||||
""" Employee can check in/out with his pin in kiosk """
|
||||
employee = self.employee_kiosk.with_user(self.user)
|
||||
employee.attendance_manual({}, entered_pin='5678')
|
||||
self.assertEqual(employee.attendance_state, 'checked_in', "He should be able to check in with his pin")
|
||||
employee.attendance_manual({}, entered_pin='5678')
|
||||
self.assertEqual(employee.attendance_state, 'checked_out', "He should be able to check out with his pin")
|
||||
|
||||
def test_checkin_kiosk_with_wrong_pin(self):
|
||||
""" Employee cannot check in/out with wrong pin in kiosk """
|
||||
employee = self.employee_kiosk.with_user(self.user)
|
||||
action = employee.attendance_manual({}, entered_pin='8888')
|
||||
self.assertNotEqual(employee.attendance_state, 'checked_in', "He should not be able to check in with a wrong pin")
|
||||
self.assertTrue(action.get('warning'))
|
||||
|
||||
def test_checkin_kiosk_without_pin(self):
|
||||
""" Employee cannot check in/out without his pin in kiosk """
|
||||
employee = self.employee_kiosk.with_user(self.user)
|
||||
action = employee.attendance_manual({}, entered_pin=None)
|
||||
self.assertNotEqual(employee.attendance_state, 'checked_in', "He should not be able to check in with no pin")
|
||||
self.assertTrue(action.get('warning'))
|
||||
|
||||
def test_checkin_kiosk_no_pin_mode(self):
|
||||
""" Employee cannot check in/out without pin in kiosk when user has not group `use_pin` """
|
||||
employee = self.employee_kiosk.with_user(self.user_no_pin)
|
||||
employee.attendance_manual({}, entered_pin=None)
|
||||
self.assertEqual(employee.attendance_state, 'checked_out', "He shouldn't be able to check in without")
|
||||
|
||||
def test_hours_today(self):
|
||||
""" Test day start is correctly computed according to the employee's timezone """
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="hr_attendance.public_kiosk_mode" name="Attendance Kiosk">
|
||||
<t t-call="web.layout">
|
||||
<t t-set="head">
|
||||
<title>Attendance Kiosk</title>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
||||
<meta http-equiv="content-type" content="text/html, charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||
<meta name="mobile-web-app-capable" content="yes"/>
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<t t-call-assets="hr_attendance.assets_public_attendance" t-js="false"/>
|
||||
<t t-call-assets="hr_attendance.assets_public_attendance" t-css="false"/>
|
||||
<t t-call="web.conditional_assets_tests">
|
||||
<t t-set="ignore_missing_deps" t-value="True"/>
|
||||
</t>
|
||||
|
||||
<script type="text/javascript">
|
||||
odoo.define("hr_attendance.public_kiosk_app", ["@hr_attendance/public_kiosk/public_kiosk_app"], function (require) {
|
||||
var { createPublicKioskAttendance } = require("@hr_attendance/public_kiosk/public_kiosk_app");
|
||||
createPublicKioskAttendance(document, <t t-out="json.dumps(kiosk_backend_info)"/>);
|
||||
});
|
||||
</script>
|
||||
</t>
|
||||
<t t-set="body">
|
||||
</t>
|
||||
<body class="o_web_client o_hr_attendance_kiosk_body">
|
||||
</body>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -7,11 +7,26 @@
|
||||
<field name="name">hr.attendance.tree</field>
|
||||
<field name="model">hr.attendance</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Employee attendances" editable="bottom" sample="1">
|
||||
<tree string="Employee attendances" decoration-success="color == 10" decoration-danger="color == 1" sample="1">
|
||||
<field name="employee_id" widget="many2one_avatar_user"/>
|
||||
<field name="check_in"/>
|
||||
<field name="check_out"/>
|
||||
<field name="check_out" options="{}"/>
|
||||
<field name="worked_hours" string="Work Hours" widget="float_time"/>
|
||||
<field name="overtime_hours" string="Over Time" optional="show" widget="float_time"/>
|
||||
<field name="color" column_invisible="1"/>
|
||||
<field name="in_latitude" optional="hidden"/>
|
||||
<field name="in_longitude" optional="hidden"/>
|
||||
<field name="out_latitude" optional="hidden"/>
|
||||
<field name="out_longitude" optional="hidden"/>
|
||||
<field name="in_country_name" optional="hidden"/>
|
||||
<field name="out_country_name" optional="hidden"/>
|
||||
<field name="in_mode" optional="hidden"/>
|
||||
<field name="out_mode" optional="hidden"/>
|
||||
<field name="in_city" optional="hidden"/>
|
||||
<field name="out_city" optional="hidden"/>
|
||||
<field name="create_uid" optional="hidden"/>
|
||||
<field name="write_uid" optional="hidden"/>
|
||||
<field name="write_date" optional="hidden"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
@@ -50,15 +65,131 @@
|
||||
<form string="Employee attendances">
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="employee_id" widget="many2one_avatar_user"/>
|
||||
<field name="check_in"/>
|
||||
<field name="check_out"/>
|
||||
<group colspan="2">
|
||||
<group col="1">
|
||||
<field name="employee_id" widget="many2one_avatar_user"/>
|
||||
<field name="check_in" options="{'rounding': 1}"/>
|
||||
<field name="check_out" options="{'rounding': 1}"/>
|
||||
</group>
|
||||
<group col="2">
|
||||
<field name="worked_hours" widget="float_time"/>
|
||||
<field name="overtime_hours" widget="float_time" string="Extra Hours"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Check In"/>
|
||||
<group colspan="2">
|
||||
<group>
|
||||
<group>
|
||||
<field name="in_mode"/>
|
||||
<field name="in_ip_address" invisible="in_mode == 'manual'"/>
|
||||
<field name="in_browser" invisible="in_mode == 'manual'"/>
|
||||
</group>
|
||||
</group>
|
||||
<group invisible="in_mode == 'manual'">
|
||||
<label for="in_country_name" string="Localisation"/>
|
||||
<div class="o_row" name="in_location_info">
|
||||
<span>
|
||||
<field name="in_country_name"/>
|
||||
</span>
|
||||
<span>
|
||||
<field name="in_city" invisible="in_city == 'Unknown'" nolabel="1"/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label for="in_latitude" string="GPS Coordinates"/>
|
||||
<div>
|
||||
<div class="o_row">
|
||||
<span>
|
||||
<field name="in_latitude"/>
|
||||
</span>,
|
||||
<span>
|
||||
<field name="in_longitude" nolabel="1"/>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
name="action_in_attendance_maps"
|
||||
type="object"
|
||||
class="btn btn-link ps-0 pt-0 pb-2"
|
||||
icon="oi-arrow-right"
|
||||
string="View on Maps"
|
||||
colspan="2"/>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Check Out" invisible="not check_out"/>
|
||||
<group colspan="2" invisible="not check_out">
|
||||
<group>
|
||||
<group>
|
||||
<field name="out_mode" string="Mode"/>
|
||||
<field name="out_ip_address" string="IP Address" invisible="in_mode == 'manual'"/>
|
||||
<field name="out_browser" string="Browser" invisible="in_mode == 'manual'"/>
|
||||
</group>
|
||||
</group>
|
||||
<group invisible="out_mode == 'manual'">
|
||||
<label for="out_country_name" string="Localisation"/>
|
||||
<div class="o_row" name="out_location_info" >
|
||||
<span>
|
||||
<field name="out_country_name"/>
|
||||
</span>
|
||||
<span>
|
||||
<field name="out_city" invisible="out_city == 'Unknown'" nolabel="1"/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label for="out_latitude" string="GPS Coordinates"/>
|
||||
<div>
|
||||
<div class="o_row">
|
||||
<span>
|
||||
<field name="out_latitude"/>
|
||||
</span>,
|
||||
<span>
|
||||
<field name="out_longitude" nolabel="1"/>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
name="action_out_attendance_maps"
|
||||
type="object"
|
||||
class="btn btn-link ps-0 pt-0 pb-2"
|
||||
icon="oi-arrow-right"
|
||||
string="View on Maps"
|
||||
colspan="2"/>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_view_graph" model="ir.ui.view">
|
||||
<field name="name">hr.attendance.graph</field>
|
||||
<field name="model">hr.attendance</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Worked Hours" type="line" stacked="0" sample="1">
|
||||
<field name="employee_id" type="row"/>
|
||||
<field name="check_in" interval="week" type="col"/>
|
||||
<field name="worked_hours" type="measure" widget="float_time"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_view_pivot" model="ir.ui.view">
|
||||
<field name="name">hr.attendance.pivot</field>
|
||||
<field name="model">hr.attendance</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot string="Worked Hours">
|
||||
<field name="employee_id" type="row"/>
|
||||
<field name="check_in" type="col" interval="month"/>
|
||||
<field name="worked_hours" type="measure" widget="float_time"/>
|
||||
<field name="overtime_hours" type="measure" widget="float_time"/>
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_view_filter" model="ir.ui.view">
|
||||
<field name="name">hr_attendance_view_filter</field>
|
||||
<field name="model">hr.attendance</field>
|
||||
@@ -66,13 +197,28 @@
|
||||
<search string="Hr Attendance Search">
|
||||
<field name="employee_id"/>
|
||||
<field name="department_id" operator="child_of"/>
|
||||
<field name="check_in"/>
|
||||
<filter string="My Attendances" name="myattendances" domain="[('employee_id.user_id', '=', uid)]" />
|
||||
<filter string="My Team" name="myteam" domain="[('employee_id.parent_id.user_id', '=', uid)]"/>
|
||||
<separator/>
|
||||
<filter string="Check In" name="check_in_filter" date="check_in" default_period="last_month"/>
|
||||
<filter string="No Check Out" name="nocheckout" domain="[('check_out', '=', False)]" />
|
||||
<filter string="At Work" name="nocheckout" domain="[('check_out', '=', False)]" />
|
||||
<filter string="Errors" name="errors"
|
||||
domain="['|', ('worked_hours', '>=', 16), '&', ('check_out', '=', False), ('check_in', '<=', (context_today() - datetime.timedelta(days=1)).strftime('%Y-%m-%d'))]" />
|
||||
<separator/>
|
||||
<filter string="Check In" name="check_in_filter" date="check_in"/>
|
||||
<filter string="Last 7 days" name="last_week" domain="[(
|
||||
'check_in','>=', (
|
||||
context_today() + datetime.timedelta(days=-7)
|
||||
)
|
||||
)]"/>
|
||||
<filter string="Last 3 Months" invisible="1" name="last_three_months" domain="[(
|
||||
'check_in','>=', (
|
||||
context_today() + datetime.timedelta(days=-90)
|
||||
)
|
||||
)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Check In" name="groupby_name" context="{'group_by': 'check_in:week'}"/>
|
||||
<filter string="Employee" name="employee" context="{'group_by': 'employee_id'}"/>
|
||||
<filter string="Check In" name="groupby_name" context="{'group_by': 'check_in'}"/>
|
||||
<filter string="Check Out" name="groupby_check_out" context="{'group_by': 'check_out'}"/>
|
||||
</group>
|
||||
</search>
|
||||
@@ -84,9 +230,13 @@
|
||||
<record id="hr_attendance_action" model="ir.actions.act_window">
|
||||
<field name="name">Attendances</field>
|
||||
<field name="res_model">hr.attendance</field>
|
||||
<field name="view_mode">tree,kanban,form</field>
|
||||
<field name="context">{"search_default_today":1}</field>
|
||||
<field name="search_view_id" ref="hr_attendance_view_filter" />
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="context">
|
||||
{
|
||||
"search_default_employee": 1
|
||||
}
|
||||
</field>
|
||||
<field name="search_view_id" ref="hr_attendance_view_filter"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_empty_folder">
|
||||
No attendance records found
|
||||
@@ -96,60 +246,50 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_action_employee" model="ir.actions.act_window">
|
||||
<record id="hr_attendance_reporting" model="ir.actions.act_window">
|
||||
<field name="name">Attendances</field>
|
||||
<field name="res_model">hr.attendance</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="context">{'create': False}</field>
|
||||
<field name="view_mode">graph,pivot</field>
|
||||
<field name="search_view_id" ref="hr_attendance_view_filter"/>
|
||||
<field name="context">
|
||||
{
|
||||
"search_default_groupby_name" : 1,
|
||||
"search_default_employee": 1,
|
||||
"search_default_last_three_months": 1
|
||||
}
|
||||
</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_empty_folder">
|
||||
No attendance records to display
|
||||
No attendance records found
|
||||
</p><p>
|
||||
The attendance records of your employees will be displayed here.
|
||||
The attendance reporting of your employees will be displayed here.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_action_overview" model="ir.actions.act_window">
|
||||
<field name="name">Attendances</field>
|
||||
<field name="res_model">hr.attendance</field>
|
||||
<field name="view_mode">tree</field>
|
||||
<field name="context">{'create': False}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_empty_folder">
|
||||
No attendance records to display
|
||||
</p><p>
|
||||
Your attendance records will be displayed here.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_action_kiosk_mode" model="ir.actions.client">
|
||||
<field name="name">Attendances</field>
|
||||
<field name="tag">hr_attendance_kiosk_mode</field>
|
||||
<field name="target">fullscreen</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_action_my_attendances" model="ir.actions.client">
|
||||
<field name="name">Attendance</field>
|
||||
<field name="tag">hr_attendance_my_attendances</field>
|
||||
<field name="target">main</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_action_greeting_message" model="ir.actions.client">
|
||||
<field name="name">Message</field>
|
||||
<field name="tag">hr_attendance_greeting_message</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.actions.server" id="open_kiosk_url">
|
||||
<field name="name">Open Kiosk Url</field>
|
||||
<field name="model_id" ref="hr_attendance.model_res_company"/>
|
||||
<field name="binding_model_id" ref="hr_attendance.model_res_company"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
action = model._action_open_kiosk_mode()
|
||||
</field>
|
||||
<field name="groups_id" eval="[(4, ref('hr_attendance.group_hr_attendance_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Menus -->
|
||||
|
||||
<menuitem id="menu_hr_attendance_root" name="Attendances" sequence="205" groups="hr_attendance.group_hr_attendance,hr_attendance.group_hr_attendance_kiosk" web_icon="hr_attendance,static/description/icon.png"/>
|
||||
<menuitem id="menu_hr_attendance_root" name="Attendances" sequence="205" groups="hr_attendance.group_hr_attendance_officer" web_icon="hr_attendance,static/description/icon.png"/>
|
||||
|
||||
<menuitem id="menu_hr_attendance_my_attendances" name="Check In / Check Out" parent="menu_hr_attendance_root" sequence="1" groups="hr_attendance.group_hr_attendance" action="hr_attendance_action_my_attendances"/>
|
||||
<menuitem id="menu_hr_attendance_kiosk_no_user_mode" name="Kiosk Mode" parent="menu_hr_attendance_root" sequence="10" groups="hr_attendance.group_hr_attendance_manager" action="open_kiosk_url"/>
|
||||
|
||||
<menuitem id="menu_hr_attendance_attendances_overview" name="Attendances" parent="menu_hr_attendance_root" sequence="1" groups="hr_attendance.group_hr_attendance" action="hr_attendance_action_overview"/>
|
||||
<menuitem id="menu_hr_attendance_reporting" name="Reporting" parent="menu_hr_attendance_root" sequence="15" groups="hr_attendance.group_hr_attendance_officer" action="hr_attendance_reporting"/>
|
||||
|
||||
<menuitem id="menu_hr_attendance_kiosk_no_user_mode" name="Kiosk Mode" parent="menu_hr_attendance_root" sequence="10" groups="hr_attendance.group_hr_attendance_kiosk" action="hr_attendance_action_kiosk_mode"/>
|
||||
|
||||
<menuitem id="menu_hr_attendance_view_attendances" name="Attendances" parent="menu_hr_attendance_root" sequence="10" groups="hr_attendance.group_hr_attendance_user" action="hr_attendance_action"/>
|
||||
<menuitem id="menu_hr_attendance_view_attendances" name="Overview" parent="menu_hr_attendance_root" sequence="5" groups="hr_attendance.group_hr_attendance_officer" action="hr_attendance_action"/>
|
||||
</odoo>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<field name="arch" type="xml">
|
||||
<data>
|
||||
<xpath expr="//div[hasclass('o_kanban_manage_reports')]" position="inside">
|
||||
<a role="menuitem" class="dropdown-item" name="%(hr_attendance_report_action_filtered)d" type="action" groups="hr_attendance.group_hr_attendance_user">
|
||||
<a role="menuitem" class="dropdown-item" name="%(hr_attendance_reporting)d" type="action" groups="hr_attendance.group_hr_attendance_officer">
|
||||
Attendances
|
||||
</a>
|
||||
</xpath>
|
||||
|
||||
@@ -8,31 +8,29 @@
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<field name="attendance_state" invisible="1"/>
|
||||
<field name="hours_last_month" groups="hr_attendance.group_hr_attendance_user" invisible="1"/>
|
||||
<button name="%(hr_attendance_action)d"
|
||||
<field name="hours_last_month" groups="hr_attendance.group_hr_attendance_officer" invisible="1"/>
|
||||
<button name="action_open_last_month_attendances"
|
||||
class="oe_stat_button"
|
||||
icon="fa-clock-o"
|
||||
type="action"
|
||||
context="{'search_default_employee_id': id, 'search_default_check_in_filter': '1'}"
|
||||
groups="hr_attendance.group_hr_attendance_user"
|
||||
type="object"
|
||||
groups="hr_attendance.group_hr_attendance_officer"
|
||||
invisible="hours_last_month == 0"
|
||||
help="Worked hours last month">
|
||||
help="Worked hours this month">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_value">
|
||||
<field name="hours_last_month_display" widget="float_time"/> Hours
|
||||
</span>
|
||||
<span class="o_stat_text">
|
||||
Last Month
|
||||
This Month
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="%(hr_attendance_overtime_action)d"
|
||||
<button name="action_open_last_month_overtime"
|
||||
class="oe_stat_button"
|
||||
icon="fa-history"
|
||||
type="action"
|
||||
type="object"
|
||||
invisible="total_overtime == 0.0"
|
||||
context="{'search_default_employee_id': id}"
|
||||
groups="hr_attendance.group_hr_attendance_user">
|
||||
groups="hr_attendance.group_hr_attendance_officer">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_value text-success" invisible="total_overtime < 0">
|
||||
<field name="total_overtime" widget="float_time"/>
|
||||
@@ -44,6 +42,12 @@
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
<xpath expr="//group[@name='managers']" position="inside">
|
||||
<field name="attendance_manager_id" string="Attendance" widget="many2one_avatar_user"/>
|
||||
</xpath>
|
||||
<xpath expr="//group[@name='managers']" position="attributes">
|
||||
<attribute name="invisible">0</attribute>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -54,30 +58,29 @@
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<field name="employee_ids" invisible="1"/>
|
||||
<button name="%(hr_attendance_action_employee)d"
|
||||
<button name="action_open_last_month_attendances"
|
||||
class="oe_stat_button"
|
||||
icon="fa-calendar"
|
||||
type="action"
|
||||
context="{'search_default_employee_id': employee_ids, 'search_default_check_in_filter': '1'}"
|
||||
type="object"
|
||||
groups="base.group_user"
|
||||
help="Worked hours last month">
|
||||
help="Worked hours this month">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_value">
|
||||
<field name="hours_last_month_display" widget="float_time"/> Hours
|
||||
</span>
|
||||
<span class="o_stat_text">
|
||||
Last Month
|
||||
This Month
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="%(hr_attendance_overtime_action)d"
|
||||
<field name="display_extra_hours" invisible="1"/>
|
||||
<button name="action_open_last_month_overtime"
|
||||
class="oe_stat_button"
|
||||
icon="fa-history"
|
||||
type="action"
|
||||
invisible="total_overtime == 0.0"
|
||||
context="{'search_default_employee_id': employee_ids}"
|
||||
type="object"
|
||||
invisible="total_overtime == 0.0 or not display_extra_hours"
|
||||
groups="base.group_user"
|
||||
help="Amount of extra hours">
|
||||
<div class="o_stat_info">
|
||||
@@ -91,6 +94,12 @@
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
<xpath expr="//group[@name='managers']" position="inside">
|
||||
<field name="attendance_manager_id" string="Attendance" widget="many2one_avatar_user"/>
|
||||
</xpath>
|
||||
<xpath expr="//group[@name='managers']" position="attributes">
|
||||
<attribute name="invisible">0</attribute>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -153,4 +162,27 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_employee_tree_inherit_leave" model="ir.ui.view">
|
||||
<field name="name">hr.employee.tree.leave</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="inherit_id" ref="hr.view_employee_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='work_location_id']" position="after">
|
||||
<field name="attendance_manager_id" optional="hide" string="Attendance" widget="many2one_avatar_user"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_attendance_employee_simple_tree_view" model="ir.ui.view">
|
||||
<field name="name">hr.attendance.tree</field>
|
||||
<field name="model">hr.attendance</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree sample="1">
|
||||
<field name="check_in"/>
|
||||
<field name="check_out"/>
|
||||
<field name="worked_hours" string="Work Hours" widget="float_time"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -8,20 +8,32 @@
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<app data-string="Attendances" string="Attendances" name="hr_attendance" groups="hr_attendance.group_hr_attendance_manager">
|
||||
<block title="Check-In/Out in Kiosk Mode" name="kiosk_mode_setting_container">
|
||||
<setting company_dependent="1" help="Define the way the user will be identified by the application.">
|
||||
<block title="Modes" name="kiosk_mode_setting_container">
|
||||
<setting string="Kiosk Mode" company_dependent="1" help="Define the way the user will be identified by the application.">
|
||||
<field name="attendance_kiosk_mode" required="1" class="w-75"/>
|
||||
</setting>
|
||||
<setting string="Attendances from Backend" company_dependent="1" help="Allow Users to Check in/out from Odoo.">
|
||||
<field name="attendance_from_systray" required="1"/>
|
||||
</setting>
|
||||
</block>
|
||||
<block title="Kiosk Settings">
|
||||
<setting invisible="attendance_kiosk_mode == 'manual'" company_dependent="1" help="Define the camera used for the barcode scan.">
|
||||
<field name="attendance_barcode_source" required="1"/>
|
||||
</setting>
|
||||
<setting string="Display Time" company_dependent="1" help="Choose how long the greeting message will be displayed.">
|
||||
<field name="attendance_kiosk_delay" required="1" class="text-center" style="width: 10%; min-width: 4rem;"/><span> seconds</span>
|
||||
</setting>
|
||||
</block>
|
||||
<block name="pincode_setting_container">
|
||||
<setting invisible="attendance_kiosk_mode == 'manual'" company_dependent="1" help="Define the camera used for the barcode scan.">
|
||||
<field name="attendance_barcode_source" required="1"/>
|
||||
</setting>
|
||||
<setting title="Set PIN codes in the employee detail form (in HR Settings tab)." invisible="attendance_kiosk_mode == 'barcode'" help="Use PIN codes (defined on the Employee's profile) to check-in.">
|
||||
<field name="group_attendance_use_pin"/>
|
||||
<field name="attendance_kiosk_use_pin"/>
|
||||
</setting>
|
||||
<setting title="Kiosk Mode Adress" help="Use this url to access your kiosk mode from any device. Warning, anybody with the link can access your kiosk.">
|
||||
<field name="attendance_kiosk_url" class="o_hr_kiosk_url_media w-100" style="width:100% !important;" widget="CopyClipboardURL"/>
|
||||
<br/>
|
||||
<span>
|
||||
If your address is compromised, you can refresh it to generate a new one.
|
||||
</span>
|
||||
<br/>
|
||||
<button name="regenerate_kiosk_key" type="object" string="Generate a new Kiosk Mode URL" class="btn-link" icon="fa-refresh"/>
|
||||
</setting>
|
||||
</block>
|
||||
<block title="Extra Hours" name="overtime_settings">
|
||||
@@ -53,6 +65,9 @@
|
||||
required="hr_attendance_overtime" style="width: 10%; min-width: 4rem;"/><span> Minutes</span>
|
||||
</div>
|
||||
</setting>
|
||||
<setting title="Display Extra Hours." string="Display Extra Hours" invisible="not hr_attendance_overtime" company_dependent="1" help="Display Extra Hours in Kiosk mode and on User profile.">
|
||||
<field name="hr_attendance_display_overtime"/>
|
||||
</setting>
|
||||
</block>
|
||||
</app>
|
||||
</xpath>
|
||||
|
||||
@@ -11,7 +11,6 @@ Convert employee's extra hours to leave allocations.
|
||||
'depends': ['hr_attendance', 'hr_holidays'],
|
||||
'auto_install': True,
|
||||
'data': [
|
||||
'security/hr_holidays_attendance_security.xml',
|
||||
'views/hr_leave_allocation_views.xml',
|
||||
'views/hr_leave_type_views.xml',
|
||||
'views/hr_leave_views.xml',
|
||||
|
||||
@@ -12,7 +12,7 @@ from odoo.tools import float_round
|
||||
class HRLeave(models.Model):
|
||||
_inherit = 'hr.leave'
|
||||
|
||||
overtime_id = fields.Many2one('hr.attendance.overtime', string='Extra Hours', groups='hr_holidays.group_hr_holidays_user')
|
||||
overtime_id = fields.Many2one('hr.attendance.overtime', string='Extra Hours')
|
||||
employee_overtime = fields.Float(related='employee_id.total_overtime')
|
||||
overtime_deductible = fields.Boolean(compute='_compute_overtime_deductible')
|
||||
|
||||
@@ -107,7 +107,7 @@ class HRLeave(models.Model):
|
||||
for d in range((leave.date_to - leave.date_from).days + 1):
|
||||
employee_dates[leave.employee_id].add(self.env['hr.attendance']._get_day_start_and_day(leave.employee_id, leave.date_from + timedelta(days=d)))
|
||||
if employee_dates:
|
||||
self.env['hr.attendance']._update_overtime(employee_dates)
|
||||
self.env['hr.attendance'].sudo()._update_overtime(employee_dates)
|
||||
|
||||
def unlink(self):
|
||||
# TODO master change to ondelete
|
||||
|
||||
@@ -75,7 +75,7 @@ class HolidaysAllocation(models.Model):
|
||||
datetime_min_time = datetime.min.time()
|
||||
start_dt = datetime.combine(start_date, datetime_min_time)
|
||||
end_dt = datetime.combine(end_date, datetime_min_time)
|
||||
attendances = self.env['hr.attendance'].search([
|
||||
attendances = self.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', self.employee_id.id),
|
||||
('check_in', '>=', start_dt),
|
||||
('check_out', '<=', end_dt),
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="hr_holidays.group_hr_holidays_user" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('hr_attendance.group_hr_attendance_user'))]"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -21,8 +21,8 @@ class TestHolidaysOvertime(TransactionCase):
|
||||
'hr_attendance_overtime': True,
|
||||
'overtime_start_date': datetime(2021, 1, 1),
|
||||
})
|
||||
cls.user = new_test_user(cls.env, login='user', groups='base.group_user,hr_attendance.group_hr_attendance', company_id=cls.company.id).with_company(cls.company)
|
||||
cls.user_manager = new_test_user(cls.env, login='manager', groups='base.group_user,hr_holidays.group_hr_holidays_user', company_id=cls.company.id).with_company(cls.company)
|
||||
cls.user = new_test_user(cls.env, login='user', groups='base.group_user', company_id=cls.company.id).with_company(cls.company)
|
||||
cls.user_manager = new_test_user(cls.env, login='manager', groups='base.group_user,hr_holidays.group_hr_holidays_user,hr_attendance.group_hr_attendance_manager', company_id=cls.company.id).with_company(cls.company)
|
||||
|
||||
cls.manager = cls.env['hr.employee'].create({
|
||||
'name': 'Dominique',
|
||||
@@ -52,7 +52,7 @@ class TestHolidaysOvertime(TransactionCase):
|
||||
})
|
||||
|
||||
def new_attendance(self, check_in, check_out=False):
|
||||
return self.env['hr.attendance'].create({
|
||||
return self.env['hr.attendance'].sudo().create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': check_in,
|
||||
'check_out': check_out,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<field name="priority" eval="120"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position="inside">
|
||||
<field name="total_overtime" invisible="1"/>
|
||||
<button
|
||||
name="%(hr_leave_allocation_overtime_manager_action)d"
|
||||
string="Deduct Extra Hours"
|
||||
|
||||
@@ -9,6 +9,6 @@ class IrUiMenu(models.Model):
|
||||
|
||||
def _load_menus_blacklist(self):
|
||||
res = super()._load_menus_blacklist()
|
||||
if not (self.env.user.has_group('hr_attendance.group_hr_attendance_user') and self.env.user.has_group('hr_timesheet.group_hr_timesheet_user')):
|
||||
if not (self.env.user.has_group('hr_timesheet.group_hr_timesheet_user')):
|
||||
res.append(self.env.ref('hr_timesheet_attendance.menu_hr_timesheet_attendance_report').id)
|
||||
return res
|
||||
|
||||
Reference in New Issue
Block a user