[REF] hr_attendance: convert legacy client actions to owl

The goal of this commit is to convert the last legacy client actions from
/hr_attendance to owl.

Client actions:
    hr_attendance_greeting_message
    hr_attendance_kiosk_confirm
    hr_attendance_kiosk_mode
    hr_attendance_my_attendances

closes odoo/odoo#110095

Taskid: 3138068
Related: odoo/enterprise#35884
Related: odoo/upgrade#4296
Signed-off-by: Adrien Dieudonné (adr) <adr@odoo.com>
This commit is contained in:
FrancoisGe
2023-02-10 17:49:45 +01:00
parent ae2de8101d
commit 01519ac2f6
24 changed files with 1057 additions and 866 deletions
@@ -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 },
};
@@ -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%;
}
}
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="barcodes.BarcodeScanner" owl="1">
<div class="o_barcode_mobile_container">
<a t-if="isBarcodeScannerSupported" role="button" class="btn btn-primary o_mobile_barcode" t-on-click="openMobileScanner">
<i class="fa fa-camera fa-2x o_barcode_mobile_camera"/>
Tap to scan
</a>
<img src="/barcodes/static/img/barcode.png" alt="Barcode"/>
<span class="o_barcode_laser"/>
</div>
</t>
</templates>
+1 -2
View File
@@ -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',
@@ -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);
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_attendance.GreetingMessage" owl="1">
<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 &gt;= 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 &gt; 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="fa fa-chevron-left me-2"/>
<span class="fs-2">Go back</span>
</button>
</div>
</t>
</CardLayout>
</templates>
@@ -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;
@@ -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);
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_attendance.UserBadge" owl="1">
<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&amp;field=avatar_128&amp;id=#{userId}" t-att-title="userName" height="80" t-att-alt="userName"/>
</div>
</t>
<t t-name="hr_attendance.KioskConfirm" owl="1">
<CardLayout>
<button class="o_hr_attendance_back_button btn btn-block btn-secondary btn-lg d-block d-md-none py-5">
<i class="fa fa-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="fa fa-2x fa-fw fa-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>
@@ -0,0 +1,93 @@
/** @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);
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_attendance.KioskMode" owl="1">
<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>
@@ -0,0 +1,40 @@
/** @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",
[["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);
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_attendance.MyAttendances" owl="1">
<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,14 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class CardLayout extends Component {}
CardLayout.template = "hr_attendance.CardLayout";
CardLayout.props = {
kioskModeClasses: { type: String, optional: true },
slots: Object,
};
CardLayout.defaultProps = {
kioskModeClasses: "",
};
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_attendance.CardLayout" owl="1">
<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">
<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">
<t t-slot="default" />
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,34 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { useDebounced } from "@web/core/utils/timing";
export class CheckInOut extends Component {
setup() {
this.actionService = useService("action");
this.orm = useService("orm");
this.notification = useService("notification");
this.onClickSignInOut = useDebounced(this.signInOut, 200, true);
}
async signInOut() {
const result = await this.orm.call("hr.employee", "attendance_manual", [
[this.props.employeeId],
this.props.nextAction,
]);
if (result.action) {
this.actionService.doAction(result.action);
} else if (result.warning) {
this.notification.add(result.warning, { type: "danger" });
}
}
}
CheckInOut.template = "hr_attendance.CheckInOut";
CheckInOut.props = {
checkedIn: Boolean,
employeeId: Number,
nextAction: String,
};
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_attendance.CheckInOut" owl="1">
<div class="flex-grow-1">
<button t-on-click="() => this.onClickSignInOut()" t-attf-class="o_hr_attendance_sign_in_out_icon btn btn-{{ props.checkedIn ? 'warning' : 'success' }} align-self-center px-5 py-3 mt-4 mb-2">
<span class="align-middle fs-2 me-3 text-white" t-if="!props.checkedIn">Check IN</span>
<i t-attf-class="fa fa-4x fa-sign-{{ props.checkedIn ? 'out' : 'in' }} align-middle"/>
<span class="align-middle fs-2 ms-3" t-if="props.checkedIn">Check OUT</span>
</button>
</div>
</t>
</templates>
@@ -1,201 +0,0 @@
odoo.define('hr_attendance.greeting_message', function (require) {
"use strict";
var AbstractAction = require('web.AbstractAction');
var core = require('web.core');
var time = require('web.time');
var field_utils = require('web.field_utils');
var _t = core._t;
var GreetingMessage = AbstractAction.extend({
contentTemplate: 'HrAttendanceGreetingMessage',
events: {
"click .o_hr_attendance_button_dismiss": function() { this.do_action(this.next_action, {clear_breadcrumbs: true}); },
},
init: function(parent, action) {
var self = this;
this._super.apply(this, arguments);
this.activeBarcode = true;
this.kioskDelay = action.kiosk_delay;
// if no correct action given (due to an erroneous back or refresh from the browser), we set the dismiss button to return
// to the (likely) appropriate menu, according to the user access rights
if(!action.attendance) {
this.activeBarcode = false;
this.getSession().user_has_group('hr_attendance.group_hr_attendance_user').then(function(has_group) {
if(has_group) {
self.next_action = 'hr_attendance.hr_attendance_action_kiosk_mode';
} else {
self.next_action = 'hr_attendance.hr_attendance_action_my_attendances';
}
});
return;
}
this.next_action = action.next_action || 'hr_attendance.hr_attendance_action_my_attendances';
// no listening to barcode scans if we aren't coming from the kiosk mode (and thus not going back to it with next_action)
if (this.next_action != 'hr_attendance.hr_attendance_action_kiosk_mode' && this.next_action.tag != 'hr_attendance_kiosk_mode') {
this.activeBarcode = false;
}
this.attendance = action.attendance;
// We receive the check in/out times in UTC
// This widget only deals with display, which should be in browser's TimeZone
this.attendance.check_in = this.attendance.check_in && moment.utc(this.attendance.check_in).local();
this.attendance.check_out = this.attendance.check_out && moment.utc(this.attendance.check_out).local();
this.previous_attendance_change_date = action.previous_attendance_change_date && moment.utc(action.previous_attendance_change_date).local();
// check in/out times displayed in the greeting message template.
this.format_time = time.getLangTimeFormat();
this.attendance.check_in_time = this.attendance.check_in && this.attendance.check_in.format(this.format_time);
this.attendance.check_out_time = this.attendance.check_out && this.attendance.check_out.format(this.format_time);
// extra hours amount displayed in the greeting message template.
this.total_overtime_float = action.total_overtime; // Used for comparison in template
this.total_overtime = field_utils.format.float_time(this.total_overtime_float);
this.today_overtime_float = action.overtime_today;
this.today_overtime = field_utils.format.float_time(this.today_overtime_float);
if (action.hours_today) {
var duration = moment.duration(action.hours_today, "hours");
this.hours_today = duration.hours() + ' hours, ' + duration.minutes() + ' minutes';
}
this.employee_name = action.employee_name;
this.attendanceBarcode = action.barcode;
},
start: function() {
if (this.attendance) {
this.attendance.check_out ? this.farewell_message() : this.welcome_message();
}
if (this.activeBarcode) {
core.bus.on('barcode_scanned', this, this._onBarcodeScanned);
}
return this._super.apply(this, arguments);
},
welcome_message: function() {
var self = this;
var now = this.attendance.check_in.clone();
if (this.kioskDelay > 0) {
this.return_to_main_menu = setTimeout( function() { self.do_action(self.next_action, {clear_breadcrumbs: true}); }, this.kioskDelay);
}
if (now.hours() < 5) {
this.$('.o_hr_attendance_message_message').append(_t("Good night"));
} else if (now.hours() < 12) {
if (now.hours() < 8 && Math.random() < 0.3) {
if (Math.random() < 0.75) {
this.$('.o_hr_attendance_message_message').append(_t("The early bird catches the worm"));
} else {
this.$('.o_hr_attendance_message_message').append(_t("First come, first served"));
}
} else {
this.$('.o_hr_attendance_message_message').append(_t("Good morning"));
}
} else if (now.hours() < 17){
this.$('.o_hr_attendance_message_message').append(_t("Good afternoon"));
} else if (now.hours() < 23){
this.$('.o_hr_attendance_message_message').append(_t("Good evening"));
} else {
this.$('.o_hr_attendance_message_message').append(_t("Good night"));
}
if(this.previous_attendance_change_date){
var last_check_out_date = this.previous_attendance_change_date.clone();
if(now - last_check_out_date > 24*7*60*60*1000){
this.$('.o_hr_attendance_random_message').html(_t("Glad to have you back, it's been a while!"));
} else {
if(Math.random() < 0.02){
this.$('.o_hr_attendance_random_message').html(_t("If a job is worth doing, it is worth doing well!"));
}
}
}
},
farewell_message: function() {
var self = this;
var now = this.attendance.check_out.clone();
if (this.kioskDelay > 0) {
this.return_to_main_menu = setTimeout( function() { self.do_action(self.next_action, {clear_breadcrumbs: true}); }, this.kioskDelay);
}
if(this.previous_attendance_change_date){
var last_check_in_date = this.previous_attendance_change_date.clone();
if(now - last_check_in_date > 1000*60*60*12){
this.$('.o_hr_attendance_warning_message').show().append(_t("<b>Warning! Last check in was over 12 hours ago.</b><br/>If this isn't right, please contact Human Resource staff"));
if (this.return_to_main_menu) {
clearTimeout(this.return_to_main_menu);
}
this.activeBarcode = false;
} else if(now - last_check_in_date > 1000*60*60*8){
this.$('.o_hr_attendance_random_message').html(_t("Another good day's work! See you soon!"));
}
}
if (now.hours() < 12) {
this.$('.o_hr_attendance_message_message').append(_t("Have a good day!"));
} else if (now.hours() < 14) {
this.$('.o_hr_attendance_message_message').append(_t("Have a nice lunch!"));
if (Math.random() < 0.05) {
this.$('.o_hr_attendance_random_message').html(_t("Eat breakfast as a king, lunch as a merchant and supper as a beggar"));
} else if (Math.random() < 0.06) {
this.$('.o_hr_attendance_random_message').html(_t("An apple a day keeps the doctor away"));
}
} else if (now.hours() < 17) {
this.$('.o_hr_attendance_message_message').append(_t("Have a good afternoon"));
} else {
if (now.hours() < 18 && Math.random() < 0.2) {
this.$('.o_hr_attendance_message_message').append(_t("Early to bed and early to rise, makes a man healthy, wealthy and wise"));
} else {
this.$('.o_hr_attendance_message_message').append(_t("Have a good evening"));
}
}
},
_onBarcodeScanned: function(barcode) {
var self = this;
if (this.attendanceBarcode !== barcode){
if (this.return_to_main_menu) { // in case of multiple scans in the greeting message view, delete the timer, a new one will be created.
clearTimeout(this.return_to_main_menu);
}
core.bus.off('barcode_scanned', this, this._onBarcodeScanned);
const kioskDelay = this.kioskDelay;
this._rpc({
model: 'hr.employee',
method: 'attendance_scan',
args: [barcode, ],
})
.then(function (result) {
if (result.action) {
self.do_action(result.action);
} else if (result.warning) {
self.displayNotification({ title: result.warning, type: 'danger' });
if (kioskDelay > 0) {
setTimeout( function() { self.do_action(self.next_action, {clear_breadcrumbs: true}); }, kioskDelay);
}
}
}, function () {
if (kioskDelay > 0) {
setTimeout( function() { self.do_action(self.next_action, {clear_breadcrumbs: true}); }, kioskDelay);
}
});
}
},
destroy: function () {
core.bus.off('barcode_scanned', this, this._onBarcodeScanned);
clearTimeout(this.return_to_main_menu);
this._super.apply(this, arguments);
},
});
core.action_registry.add('hr_attendance_greeting_message', GreetingMessage);
return GreetingMessage;
});
@@ -1,98 +0,0 @@
odoo.define('hr_attendance.kiosk_confirm', function (require) {
"use strict";
var AbstractAction = require('web.AbstractAction');
var core = require('web.core');
var field_utils = require('web.field_utils');
var QWeb = core.qweb;
const session = require('web.session');
var KioskConfirm = AbstractAction.extend({
events: {
"click .o_hr_attendance_back_button": function () { this.do_action(this.next_action, {clear_breadcrumbs: true}); },
"click .o_hr_attendance_sign_in_out_icon": _.debounce(function () {
var self = this;
this._rpc({
model: 'hr.employee',
method: 'attendance_manual',
args: [[this.employee_id], this.next_action],
context: session.user_context,
})
.then(function(result) {
if (result.action) {
self.do_action(result.action);
} else if (result.warning) {
self.displayNotification({ title: result.warning, type: 'danger' });
}
});
}, 200, true),
'click .o_hr_attendance_pin_pad_button_0': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 0); },
'click .o_hr_attendance_pin_pad_button_1': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 1); },
'click .o_hr_attendance_pin_pad_button_2': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 2); },
'click .o_hr_attendance_pin_pad_button_3': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 3); },
'click .o_hr_attendance_pin_pad_button_4': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 4); },
'click .o_hr_attendance_pin_pad_button_5': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 5); },
'click .o_hr_attendance_pin_pad_button_6': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 6); },
'click .o_hr_attendance_pin_pad_button_7': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 7); },
'click .o_hr_attendance_pin_pad_button_8': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 8); },
'click .o_hr_attendance_pin_pad_button_9': function() { this.$('.o_hr_attendance_PINbox').val(this.$('.o_hr_attendance_PINbox').val() + 9); },
'click .o_hr_attendance_pin_pad_button_C': function() { this.$('.o_hr_attendance_PINbox').val(''); },
'click .o_hr_attendance_pin_pad_button_ok': _.debounce(function() {
var self = this;
this.$('.o_hr_attendance_pin_pad_button_ok').attr("disabled", "disabled");
this._rpc({
model: 'hr.employee',
method: 'attendance_manual',
args: [[this.employee_id], this.next_action, this.$('.o_hr_attendance_PINbox').val()],
context: session.user_context,
})
.then(function(result) {
if (result.action) {
self.do_action(result.action);
} else if (result.warning) {
self.displayNotification({ title: result.warning, type: 'danger' });
self.$('.o_hr_attendance_PINbox').val('');
setTimeout( function() { self.$('.o_hr_attendance_pin_pad_button_ok').removeAttr("disabled"); }, 500);
}
});
}, 200, true),
},
init: function (parent, action) {
this._super.apply(this, arguments);
this.next_action = 'hr_attendance.hr_attendance_action_kiosk_mode';
this.employee_id = action.employee_id;
this.employee_name = action.employee_name;
this.employee_state = action.employee_state;
this.employee_hours_today = field_utils.format.float_time(action.employee_hours_today);
},
start: function () {
var self = this;
this.getSession().user_has_group('hr_attendance.group_hr_attendance_use_pin').then(function(has_group){
self.use_pin = has_group;
self.$el.html(QWeb.render("HrAttendanceKioskConfirm", {widget: self}));
self.start_clock();
});
return self._super.apply(this, arguments);
},
start_clock: function () {
this.clock_start = setInterval(function() {this.$(".o_hr_attendance_clock").text(new Date().toLocaleTimeString(navigator.language, {hour: '2-digit', minute:'2-digit', second:'2-digit'}));}, 500);
// First clock refresh before interval to avoid delay
this.$(".o_hr_attendance_clock").show().text(new Date().toLocaleTimeString(navigator.language, {hour: '2-digit', minute:'2-digit', second:'2-digit'}));
},
destroy: function () {
clearInterval(this.clock_start);
this._super.apply(this, arguments);
},
});
core.action_registry.add('hr_attendance_kiosk_confirm', KioskConfirm);
return KioskConfirm;
});
@@ -1,94 +0,0 @@
odoo.define('hr_attendance.kiosk_mode', function (require) {
"use strict";
var AbstractAction = require('web.AbstractAction');
var ajax = require('web.ajax');
var core = require('web.core');
var Session = require('web.session');
var QWeb = core.qweb;
var KioskMode = AbstractAction.extend({
events: {
"click .o_hr_attendance_button_employees": function() {
this.do_action('hr_attendance.hr_employee_attendance_action_kanban', {
additional_context: {'no_group_by': true},
});
},
},
start: function () {
var self = this;
core.bus.on('barcode_scanned', this, this._onBarcodeScanned);
self.session = Session;
const company_id = this.session.user_context.allowed_company_ids[0];
var def = this._rpc({
model: 'res.company',
method: 'search_read',
args: [[['id', '=', company_id]], ['name', 'attendance_kiosk_mode', 'attendance_barcode_source']],
})
.then(function (companies){
self.company_name = companies[0].name;
self.company_image_url = self.session.url('/web/image', {model: 'res.company', id: company_id, field: 'logo',});
self.kiosk_mode = companies[0].attendance_kiosk_mode;
self.barcode_source = companies[0].attendance_barcode_source;
self.$el.html(QWeb.render("HrAttendanceKioskMode", {widget: self}));
self.start_clock();
});
// Make a RPC call every day to keep the session alive
self._interval = window.setInterval(this._callServer.bind(this), (60*60*1000*24));
return Promise.all([def, this._super.apply(this, arguments)]);
},
on_attach_callback: function () {
// Stop the bus_service to avoid notifications in kiosk mode
this.call('bus_service', 'stop');
$('body').find('.o_ChatWindowHeaderView_commandClose').click();
},
_onBarcodeScanned: function(barcode) {
var self = this;
core.bus.off('barcode_scanned', this, this._onBarcodeScanned);
this._rpc({
model: 'hr.employee',
method: 'attendance_scan',
args: [barcode, ],
})
.then(function (result) {
if (result.action) {
self.do_action(result.action);
} else if (result.warning) {
self.displayNotification({ title: result.warning, type: 'danger' });
core.bus.on('barcode_scanned', self, self._onBarcodeScanned);
}
}, function () {
core.bus.on('barcode_scanned', self, self._onBarcodeScanned);
});
},
start_clock: function() {
this.clock_start = setInterval(function() {this.$(".o_hr_attendance_clock").text(new Date().toLocaleTimeString(navigator.language, {hour: '2-digit', minute:'2-digit', second:'2-digit'}));}, 500);
// First clock refresh before interval to avoid delay
this.$(".o_hr_attendance_clock").show().text(new Date().toLocaleTimeString(navigator.language, {hour: '2-digit', minute:'2-digit', second:'2-digit'}));
},
destroy: function () {
core.bus.off('barcode_scanned', this, this._onBarcodeScanned);
clearInterval(this.clock_start);
clearInterval(this._interval);
this._super.apply(this, arguments);
},
_callServer: function () {
// Make a call to the database to avoid the auto close of the session
return ajax.rpc("/hr_attendance/kiosk_keepalive", {});
},
});
core.action_registry.add('hr_attendance_kiosk_mode', KioskMode);
return KioskMode;
});
@@ -1,59 +0,0 @@
odoo.define('hr_attendance.my_attendances', function (require) {
"use strict";
var AbstractAction = require('web.AbstractAction');
var core = require('web.core');
var field_utils = require('web.field_utils');
const session = require('web.session');
var MyAttendances = AbstractAction.extend({
contentTemplate: 'HrAttendanceMyMainMenu',
events: {
"click .o_hr_attendance_sign_in_out_icon": _.debounce(function() {
this.update_attendance();
}, 200, true),
},
willStart: function () {
var self = this;
var def = this._rpc({
model: 'hr.employee',
method: 'search_read',
args: [[['user_id', '=', this.getSession().uid]], ['attendance_state', 'name', 'hours_today']],
context: session.user_context,
})
.then(function (res) {
self.employee = res.length && res[0];
if (res.length) {
self.hours_today = field_utils.format.float_time(self.employee.hours_today);
}
});
return Promise.all([def, this._super.apply(this, arguments)]);
},
update_attendance: function () {
var self = this;
this._rpc({
model: 'hr.employee',
method: 'attendance_manual',
args: [[self.employee.id], 'hr_attendance.hr_attendance_action_my_attendances'],
context: session.user_context,
})
.then(function(result) {
if (result.action) {
self.do_action(result.action);
} else if (result.warning) {
self.displayNotification({ title: result.warning, type: 'danger' });
}
});
},
});
core.action_registry.add('hr_attendance_my_attendances', MyAttendances);
return MyAttendances;
});
@@ -1,205 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<template xml:space="preserve">
<t t-name="PresenceIndicator">
<div id="oe_hr_attendance_status" class="fa fa-circle me-1 text-400 " role="img" aria-label="Available" title="Available">
</div>
</t>
<t t-name="HrAttendanceCardLayout">
<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">
<span class="o_hr_attendance_kiosk_backdrop position-absolute top-0 start-0 end-0 bottom-0 bg-black-25"/>
<div class="o_hr_attendance_clock bg-black-50 p-3 py-md-2 m-0 mt-md-5 me-md-5 h2 text-white font-monospace"/>
<div t-attf-class="o_hr_attendance_kiosk_mode flex-grow-1 flex-md-grow-0 card pb-3 px-0 px-lg-5 {{kioskModeClasses ? kioskModeClasses : '' }}">
<div class="card-body d-flex flex-column p-0 p-md-4">
<t t-out="bodyContent"></t>
</div>
</div>
</div>
</t>
<t t-name="HrAttendanceUserBadge">
<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&amp;field=avatar_128&amp;id=#{userId}" t-att-title="userName" height="80" t-att-alt="userName"/>
</div>
</t>
<t t-name="HrAttendanceCheckInOutButtons">
<div class="flex-grow-1">
<button t-attf-class="o_hr_attendance_sign_in_out_icon btn btn-{{ checked_in ? 'warning' : 'success' }} align-self-center px-5 py-3 mt-4 mb-2">
<span class="align-middle fs-2 me-3 text-white" t-if="!checked_in">Check IN</span>
<i t-attf-class="fa fa-4x fa-sign-{{ checked_in ? 'out' : 'in' }} align-middle"/>
<span class="align-middle fs-2 ms-3" t-if="checked_in">Check OUT</span>
</button>
</div>
</t>
<t t-name="HrAttendanceKioskMode">
<t t-call="HrAttendanceCardLayout">
<t t-set="kioskModeClasses">o_barcode_main pt-5</t>
<t t-set="bodyContent">
<h2 class="mb-2"><small>Welcome to</small> <t t-esc="widget.company_name"/></h2>
<img t-attf-src="{{widget.company_image_url}}" alt="Company Logo" class="o_hr_attendance_kiosk_company_image 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="widget.kiosk_mode != 'manual'">
<img src="/barcodes/static/img/barcode.png" alt="Barcode" style="width: 115px;height: 60px"/>
<h6 class="mt-2 text-muted">Scan your badge</h6>
</div>
<div class="mt-5 align-self-end" t-if="widget.kiosk_mode == 'barcode_manual'">
<button class="o_hr_attendance_button_employees btn btn-link">
Identify Manually
</button>
</div>
<div class="mt-5 align-self-center" t-if="widget.kiosk_mode == 'manual'">
<button class="o_hr_attendance_button_employees btn btn-primary px-5 py-3 mt-4 mb-2">
<span class="fs-2">Identify Manually</span>
</button>
</div>
</div>
</t>
</t>
</t>
<t t-name="HrAttendanceMyMainMenu">
<t t-call="HrAttendanceCardLayout">
<t t-set="bodyContent">
<t t-if="widget.employee">
<t t-set="checked_in" t-value="widget.employee.attendance_state=='checked_in'"/>
<t t-call="HrAttendanceUserBadge">
<t t-set="userId" t-value="widget.employee.id"/>
<t t-set="userName" t-value="widget.employee.name"/>
</t>
<div class="flex-grow-1">
<h1 class="mt-5" t-esc="widget.employee.name"/>
<h3><t t-if="!checked_in">Welcome!</t><t t-else="">Want to check out?</t></h3>
<h4 class="mt0 mb0 text-muted" t-if="checked_in">Today's work hours: <span t-esc="widget.hours_today"/></h4>
</div>
<t t-call="HrAttendanceCheckInOutButtons"/>
</t>
<div class="alert alert-warning" t-else="">
<b>Warning</b> : Your user should be linked to an employee to use attendance.<br/> Please contact your administrator.
</div>
</t>
</t>
</t>
<t t-name="HrAttendanceKioskConfirm">
<t t-call="HrAttendanceCardLayout">
<t t-set="bodyContent">
<t t-set="checked_in" t-value="widget.employee_state=='checked_in'"/>
<button class="o_hr_attendance_back_button btn btn-block btn-secondary btn-lg d-block d-md-none py-5">
<i class="fa fa-chevron-left me-2"/> Go back
</button>
<t t-if="widget.employee_id" t-call="HrAttendanceUserBadge">
<t t-set="userId" t-value="widget.employee_id"/>
<t t-set="userName" t-value="widget.employee_name"/>
</t>
<button 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="fa fa-2x fa-fw fa-chevron-left me-1" role="img" aria-label="Go back" title="Go back"/>
</button>
<div t-if="widget.employee_id" class="flex-grow-1">
<h1 class="mt-5 mb8"><t t-esc="widget.employee_name"/></h1>
<h3 class="mt8 mb24"><t t-if="!checked_in">Welcome!</t><t t-else="">Want to check out?</t></h3>
<h4 class="mt0 mb0 text-muted" t-if="checked_in">Today's work hours: <span t-esc="widget.employee_hours_today"/></h4>
<t t-if="!widget.use_pin" t-call="HrAttendanceCheckInOutButtons"/>
<t t-else="">
<h3 class="mt-4 mb0 text-muted">Please enter your PIN to <b t-if="checked_in">check out</b><b t-else="">check in</b></h3>
<div class="row">
<div class="col-md-8 offset-md-2 o_hr_attendance_pin_pad">
<div class="row g-0" >
<div class="col-12 mb8 mt8">
<input 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="['1', '2', '3', '4', '5', '6', '7', '8', '9', ['C', 'btn-warning'], '0', ['ok', 'btn-primary']]" t-as="btn_name">
<div class="col-4 p-1">
<a href="#" t-attf-class="o_hr_attendance_PINbox_button btn {{btn_name[1]? btn_name[1] : 'btn-secondary border'}} btn-block btn-lg {{ 'o_hr_attendance_pin_pad_button_' + btn_name[0] }} d-flex align-items-center justify-content-center">
<t t-esc="btn_name[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"/>
</t>
</t>
</t>
<t t-name="HrAttendanceGreetingMessage">
<t t-call="HrAttendanceCardLayout">
<t t-set="bodyContent">
<t t-set="checked_in" t-value="widget.employee_state=='checked_in'"/>
<t t-if="widget.attendance">
<t t-call="HrAttendanceUserBadge">
<t t-set="userId" t-value="widget.attendance.employee_id[0]"/>
<t t-set="userName" t-value="widget.employee_name"/>
</t>
<div t-if="widget.attendance.check_out" class="flex-grow-1">
<h1 class="mt-5">Goodbye <t t-esc="widget.employee_name"/>!</h1>
<h2 class="o_hr_attendance_message_message mt4 mb24"/>
<div class="alert alert-info fs-2 mx-3" role="status">
Checked out at <b><t t-esc="widget.attendance.check_out_time"/></b>
<br/><b><t t-esc="widget.hours_today"/></b>
</div>
<div t-att-class="'alert ' + (widget.today_overtime_float &gt;= 0 ? 'alert-success' : 'alert-danger') + ' h3 mx-3'" role="status">
Extra hours today:
<span t-esc="widget.today_overtime"/>
</div>
<t t-if="widget.total_overtime_float &gt; 0">
Total extra hours: <span t-esc="widget.total_overtime"/>
</t>
<h3 class="o_hr_attendance_random_message fst-italic mb24"/>
<div class="o_hr_attendance_warning_message mt24 alert alert-warning" style="display:none" role="alert"/>
</div>
<div t-else="" class="flex-grow-1">
<h1 class="mt-5 mb0">Welcome <t t-esc="widget.employee_name"/>!</h1>
<h2 class="o_hr_attendance_message_message mt4 mb24"/>
<div class="alert alert-info fs-2 mx-3" role="status">
Checked in at <b><t t-esc="widget.attendance.check_in_time"/></b>
</div>
<h3 class="o_hr_attendance_random_message mb24"/>
<div class="o_hr_attendance_warning_message mt24 alert alert-warning" style="display:none" role="alert"/>
</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">
<span class="fs-2" t-if="widget.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">
<i class="fa fa-chevron-left me-2"/>
<span class="fs-2">Go back</span>
</button>
</div>
</t>
</t>
</t>
</t>
</template>
@@ -0,0 +1,229 @@
/** @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": {
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"].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"
);
});
});
@@ -1,197 +0,0 @@
odoo.define('hr_attendance.tests', function (require) {
"use strict";
var testUtils = require('web.test_utils');
var core = require('web.core');
var MyAttendances = require('hr_attendance.my_attendances');
var KioskMode = require('hr_attendance.kiosk_mode');
var GreetingMessage = require('hr_attendance.greeting_message');
QUnit.module('HR Attendance', {
beforeEach: function () {
this.data = {
'hr.employee': {
fields: {
name: {string: 'Name', type: 'char'},
attendance_state: {
string: 'State',
type: 'selection',
selection: [['checked_in', "In"], ['checked_out', "Out"]],
default: 1,
},
user_id: {string: 'user ID', type: 'integer'},
barcode: {string:'barcode', type: 'integer'},
hours_today: {string:'Hours today', type: 'float'},
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',
}],
},
};
},
}, function () {
QUnit.module('My attendances (client action)');
QUnit.test('simple rendering', async function (assert) {
assert.expect(1);
var $target = $('#qunit-fixture');
var clientAction = new MyAttendances(null, {});
await testUtils.mock.addMockEnvironment(clientAction, {
data: this.data,
session: {
uid: 1,
},
});
await clientAction.appendTo($target);
assert.strictEqual(clientAction.$('.o_hr_attendance_kiosk_mode h1').text(), 'Employee A',
"should have rendered the client action without crashing");
clientAction.destroy();
});
QUnit.test('Attendance Kiosk Mode Test', async function (assert) {
assert.expect(2);
var $target = $('#qunit-fixture');
var self = this;
var rpcCount = 0;
var clientAction = new KioskMode(null, {});
await testUtils.mock.addMockEnvironment(clientAction, {
data: this.data,
session: {
uid: 1,
user_context: {
allowed_company_ids: [1],
}
},
mockRPC: function(route, args) {
if (args.method === 'attendance_scan' && args.model === 'hr.employee') {
rpcCount++;
return Promise.resolve(self.data['hr.employee'].records[0]);
}
return this._super(route, args);
},
});
await clientAction.appendTo($target);
core.bus.trigger('barcode_scanned', 1);
core.bus.trigger('barcode_scanned', 1);
assert.strictEqual(rpcCount, 1, 'RPC call should have been done only once.');
core.bus.trigger('barcode_scanned', 2);
assert.strictEqual(rpcCount, 1, 'RPC call should have been done only once.');
clientAction.destroy();
});
QUnit.test('Attendance Greeting Message Test', async function (assert) {
assert.expect(10);
var $target = $('#qunit-fixture');
var self = this;
var rpcCount = 0;
var clientActions = [];
let greetingMessageCreated;
async function createGreetingMessage (target, barcode){
var action = {
attendance: {
check_in: "2018-09-20 13:41:13",
employee_id: [barcode],
},
next_action: "hr_attendance.hr_attendance_action_kiosk_mode",
barcode: barcode,
};
var clientAction = new GreetingMessage(null, action);
await testUtils.mock.addMockEnvironment(clientAction, {
data: self.data,
session: {
uid: 1,
company_id: 1,
},
mockRPC: function(route, args) {
if (args.method === 'attendance_scan' && args.model === 'hr.employee') {
rpcCount++;
action.attendance.employee_id = [args.args[0], 'Employee'];
/*
if rpc have been made, a new instance is created to simulate the same behaviour
as functional flow.
*/
greetingMessageCreated = createGreetingMessage (target, args.args[0]);
return Promise.resolve({action: action});
}
return this._super(route, args);
},
});
await clientAction.appendTo(target);
clientActions.push(clientAction);
}
// init - mock coming from kiosk
await createGreetingMessage ($target, 1);
await testUtils.nextMicrotaskTick();
assert.strictEqual(clientActions.length, 1, 'Number of clientAction must = 1.');
core.bus.trigger('barcode_scanned', 1);
/*
As action is given when instantiate GreetingMessage, we simulate that we come from the KioskMode
So rescanning the same barcode won't lead to another RPC.
*/
assert.strictEqual(clientActions.length, 1, 'Number of clientActions must = 1.');
assert.strictEqual(rpcCount, 0, 'RPC call should not have been done.');
core.bus.trigger('barcode_scanned', 2);
await greetingMessageCreated;
assert.strictEqual(clientActions.length, 2, 'Number of clientActions must = 2.');
assert.strictEqual(rpcCount, 1, 'RPC call should have been done only once.');
core.bus.trigger('barcode_scanned', 2);
await testUtils.nextMicrotaskTick();
assert.strictEqual(clientActions.length, 2, 'Number of clientActions must = 2.');
assert.strictEqual(rpcCount, 1, 'RPC call should have been done only once.');
core.bus.trigger('barcode_scanned', 1);
await greetingMessageCreated;
assert.strictEqual(clientActions.length, 3, 'Number of clientActions must = 3.');
core.bus.trigger('barcode_scanned', 1);
await testUtils.nextMicrotaskTick();
assert.strictEqual(clientActions.length, 3, 'Number of clientActions must = 3.');
assert.strictEqual(rpcCount, 2, 'RPC call should have been done only twice.');
_.each(clientActions.reverse(), function(clientAction) {
clientAction.destroy();
});
});
});
});