diff --git a/addons/barcodes/static/src/components/barcode_scanner.js b/addons/barcodes/static/src/components/barcode_scanner.js new file mode 100644 index 00000000000..f9f1a9cb383 --- /dev/null +++ b/addons/barcodes/static/src/components/barcode_scanner.js @@ -0,0 +1,42 @@ +/** @odoo-module **/ + +import { isBarcodeScannerSupported, scanBarcode } from "@web/webclient/barcode/barcode_scanner"; +import { Component } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; + +export class BarcodeScanner extends Component { + setup() { + this.notification = useService("notification"); + this.isBarcodeScannerSupported = isBarcodeScannerSupported(); + } + + get facingMode() { + return "environment"; + } + + async openMobileScanner() { + let error = null; + let barcode = null; + try { + barcode = await scanBarcode(this.facingMode); + } catch (err) { + error = err.error.message; + } + + if (barcode) { + this.props.onBarcodeScanned(barcode); + if ("vibrate" in window.navigator) { + window.navigator.vibrate(100); + } + } else { + this.notification.add(error || this.env._t("Please, Scan again !"), { + type: "warning", + }); + } + } +} + +BarcodeScanner.template = "barcodes.BarcodeScanner"; +BarcodeScanner.props = { + onBarcodeScanned: { type: Function }, +}; diff --git a/addons/barcodes/static/src/components/barcode_scanner.scss b/addons/barcodes/static/src/components/barcode_scanner.scss new file mode 100644 index 00000000000..20fda86a787 --- /dev/null +++ b/addons/barcodes/static/src/components/barcode_scanner.scss @@ -0,0 +1,66 @@ +.o_barcode_mobile_container { + position: relative; + display: inline-block; + font-family: 'Lato', sans-serif; + font-size: 1.08333333rem; + font-weight: 400; + line-height: 1.5; + color: #fff; + + img, .o_mobile_barcode { + width: 115px; + height: 60px; + } + + .o_mobile_barcode { + width: 100%; + bottom: 0; + position: absolute; + opacity: 0.75; + padding-top: 5px; + + .o_barcode_mobile_camera { + margin: 5px; + font-size: 1em; + } + + div { + font-size: 12px; + } + } + + .o_barcode_laser { + // avoid dependencies to web file in pos + position: absolute; + top: 50%; + left: -15px; + bottom: auto; + right: -15px; + + height: 5px; + background: rgba(red, 0.6); + box-shadow: 0 1px 10px 1px rgba(red, 0.8); + animation: o_barcode_scanner_intro 1s cubic-bezier(0.6, -0.28, 0.735, 0.045) 0.4s; + } + + @keyframes o_barcode_scanner_intro { + 25% { + top: 75%; + } + 50% { + top: 0; + } + 75% { + top: 100%; + } + 100% { + top: 50%; + } + } +} +@media screen and (max-width: 576px) { + .o_event_barcode_bg.o_home_menu_background { + background: white; + height: 100%; + } +} diff --git a/addons/barcodes/static/src/components/barcode_scanner.xml b/addons/barcodes/static/src/components/barcode_scanner.xml new file mode 100644 index 00000000000..0468e49a7be --- /dev/null +++ b/addons/barcodes/static/src/components/barcode_scanner.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/addons/hr_attendance/__manifest__.py b/addons/hr_attendance/__manifest__.py index 1e3f0999316..18a0319e941 100644 --- a/addons/hr_attendance/__manifest__.py +++ b/addons/hr_attendance/__manifest__.py @@ -35,10 +35,9 @@ actions(Check in/Check out) performed by them. 'assets': { 'web.assets_backend': [ 'hr_attendance/static/src/**/*', - 'hr_attendance/static/src/xml/**/*', ], 'web.qunit_suite_tests': [ - 'hr_attendance/static/tests/hr_attendance_tests.js', + 'hr_attendance/static/tests/client_action_tests.js', ], }, 'license': 'LGPL-3', diff --git a/addons/hr_attendance/static/src/client_action/greeting_message/greeting_message.js b/addons/hr_attendance/static/src/client_action/greeting_message/greeting_message.js new file mode 100644 index 00000000000..66f910b8720 --- /dev/null +++ b/addons/hr_attendance/static/src/client_action/greeting_message/greeting_message.js @@ -0,0 +1,226 @@ +/** @odoo-module **/ + +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"; +import { sprintf } from "@web/core/utils/strings"; + +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 = sprintf(this.env._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 { _t } = this.env; + 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 { _t } = this.env; + 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); diff --git a/addons/hr_attendance/static/src/client_action/greeting_message/greeting_message.xml b/addons/hr_attendance/static/src/client_action/greeting_message/greeting_message.xml new file mode 100644 index 00000000000..b753e7161cd --- /dev/null +++ b/addons/hr_attendance/static/src/client_action/greeting_message/greeting_message.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + +
+

Goodbye !

+

+
+ Checked out at +
+
+
+ Extra hours today: + +
+ + Total extra hours: + +

+ +

+
+

Welcome !

+

+
+ Checked in at +
+

+ +
+ +
+ + +
+ +
+
+ +
+
+ + + diff --git a/addons/hr_attendance/static/src/scss/hr_attendance.scss b/addons/hr_attendance/static/src/client_action/hr_attendance.scss similarity index 71% rename from addons/hr_attendance/static/src/scss/hr_attendance.scss rename to addons/hr_attendance/static/src/client_action/hr_attendance.scss index ae67df30e08..88668b0cf34 100644 --- a/addons/hr_attendance/static/src/scss/hr_attendance.scss +++ b/addons/hr_attendance/static/src/client_action/hr_attendance.scss @@ -1,19 +1,10 @@ -.o_hr_attendance_kiosk_mode, .o_hr_attendance_clock { +.o_hr_attendance_kiosk_mode { @include media-breakpoint-down(md) { width: 100%; } } -.o_hr_attendance_clock { - position: relative; - display: none; - - @include media-breakpoint-up(md) { - @include o-position-absolute(0, 0); // Fine-tuned by margin classes - } -} - .o_hr_attendance_kiosk_mode { @include media-breakpoint-up(md) { min-width: map-get($grid-breakpoints, 'md') * .6; diff --git a/addons/hr_attendance/static/src/client_action/kiosk_confirm/kiosk_confirm.js b/addons/hr_attendance/static/src/client_action/kiosk_confirm/kiosk_confirm.js new file mode 100644 index 00000000000..39fad9ac3f3 --- /dev/null +++ b/addons/hr_attendance/static/src/client_action/kiosk_confirm/kiosk_confirm.js @@ -0,0 +1,80 @@ +/** @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"); + } + + 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); diff --git a/addons/hr_attendance/static/src/client_action/kiosk_confirm/kiosk_confirm.xml b/addons/hr_attendance/static/src/client_action/kiosk_confirm/kiosk_confirm.xml new file mode 100644 index 00000000000..0f14e86f849 --- /dev/null +++ b/addons/hr_attendance/static/src/client_action/kiosk_confirm/kiosk_confirm.xml @@ -0,0 +1,65 @@ + + + + + + +
+ +
+
+ + + + + + + + + + + + + +
+

+

Welcome!Want to check out?

+

Today's work hours:

+ + + + +

Please enter your PIN to check outcheck in

+
+
+
+
+ +
+
+
+ +
+ + + +
+
+
+
+
+
+
+ +