The "explain your missed clock-out" dialog (driven by hr.employee. x_fclk_pending_reason) was set by the absence + auto-clock-out crons but only cleared by the systray reason dialog -- never by the kiosk/NFC clock paths that staff actually use. During the kiosk rollout the absence cron flagged the whole company (hundreds of "absent" logs); those stale flags then nagged everyone forever, even while currently clocked in. Fixes: - Clear x_fclk_pending_reason on every successful clock-in (portal, systray, PIN kiosk, NFC kiosk). Back on the clock => no nag. - get_status / dashboard never report pending while checked-in or exempt; the systray also guards the dialog client-side. - Absence detection no longer sets x_fclk_pending_reason (an absence has no "departure time" to explain). It still logs 'absent' + notifies the office. - One-time migration (19.0.4.2.0) clears existing stale flags. Owner / attendance exemption: - New "Owner" role (top of the Fusion Clock access dropdown, implies Manager) plus a per-employee "Exempt from Attendance" checkbox. - hr.employee._fclk_is_attendance_exempt(); the absence, auto-clock-out, reminder and weekly-summary crons all skip exempt employees, and the dialog is suppressed for them. Tests: tests/test_pending_reason_exempt.py (13 cases). Full fusion_clock suite green except pre-existing env-sensitive failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
313 lines
11 KiB
JavaScript
313 lines
11 KiB
JavaScript
/** @odoo-module **/
|
|
|
|
import { Component, useState, onWillStart, onMounted, onWillUnmount } from "@odoo/owl";
|
|
import { Dropdown } from "@web/core/dropdown/dropdown";
|
|
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
|
import { useDropdownState } from "@web/core/dropdown/dropdown_hooks";
|
|
import { rpc } from "@web/core/network/rpc";
|
|
import { registry } from "@web/core/registry";
|
|
import { useService } from "@web/core/utils/hooks";
|
|
|
|
export class FusionClockFAB extends Component {
|
|
static props = {};
|
|
static template = "fusion_clock.ClockSystray";
|
|
static components = { Dropdown, DropdownItem };
|
|
|
|
setup() {
|
|
this.notification = useService("notification");
|
|
this.dropdown = useDropdownState();
|
|
|
|
this.state = useState({
|
|
isCheckedIn: false,
|
|
isDisplayed: false,
|
|
checkInTime: null,
|
|
locationName: "",
|
|
timerDisplay: "00:00:00",
|
|
todayHours: "0.0",
|
|
weekHours: "0.0",
|
|
loading: false,
|
|
error: "",
|
|
showReasonDialog: false,
|
|
showClockoutConfirm: false,
|
|
reasonText: "",
|
|
reasonTime: "",
|
|
reasonSubmitting: false,
|
|
});
|
|
|
|
this._timerInterval = null;
|
|
|
|
onWillStart(async () => {
|
|
await this._fetchStatus();
|
|
});
|
|
|
|
onMounted(() => {
|
|
if (this.state.isCheckedIn) {
|
|
this._startTimer();
|
|
}
|
|
this._pollInterval = setInterval(() => this._fetchStatus(), 15000);
|
|
|
|
this._onFocus = () => this._fetchStatus();
|
|
window.addEventListener("focus", this._onFocus);
|
|
});
|
|
|
|
onWillUnmount(() => {
|
|
this._stopTimer();
|
|
if (this._pollInterval) clearInterval(this._pollInterval);
|
|
if (this._onFocus) window.removeEventListener("focus", this._onFocus);
|
|
});
|
|
}
|
|
|
|
// =================================================================
|
|
// Server sync
|
|
// =================================================================
|
|
|
|
async _fetchStatus() {
|
|
try {
|
|
const result = await rpc("/fusion_clock/get_status", {});
|
|
if (result.error) return;
|
|
|
|
this.state.isDisplayed = result.enable_clock !== false;
|
|
this.state.locationName = result.location_name || "";
|
|
this.state.todayHours = (result.today_hours || 0).toFixed(1);
|
|
this.state.weekHours = (result.week_hours || 0).toFixed(1);
|
|
|
|
// Never raise the missed-clock-out dialog while the employee is
|
|
// currently on the clock (the server already guards this, but keep
|
|
// the UI honest too).
|
|
if (result.pending_reason && !result.is_checked_in) {
|
|
this.state.showReasonDialog = true;
|
|
}
|
|
|
|
if (result.is_checked_in && result.check_in) {
|
|
const serverTime = new Date(result.check_in + "Z");
|
|
const wasRunning = this.state.isCheckedIn;
|
|
this.state.isCheckedIn = true;
|
|
if (!wasRunning || Math.abs(serverTime - this.state.checkInTime) > 5000) {
|
|
this.state.checkInTime = serverTime;
|
|
this._startTimer();
|
|
}
|
|
try { localStorage.setItem("fclk_fab_check_in", serverTime.toISOString()); } catch {}
|
|
} else if (!result.is_checked_in) {
|
|
this.state.isCheckedIn = false;
|
|
this.state.checkInTime = null;
|
|
this._stopTimer();
|
|
this.state.timerDisplay = "00:00:00";
|
|
try { localStorage.removeItem("fclk_fab_check_in"); } catch {}
|
|
}
|
|
} catch {
|
|
if (!this.state.isCheckedIn) {
|
|
try {
|
|
const saved = localStorage.getItem("fclk_fab_check_in");
|
|
if (saved) {
|
|
const t = new Date(saved);
|
|
if (!isNaN(t.getTime()) && (Date.now() - t.getTime()) < 24 * 60 * 60 * 1000) {
|
|
this.state.isDisplayed = true;
|
|
this.state.isCheckedIn = true;
|
|
this.state.checkInTime = t;
|
|
this._startTimer();
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// =================================================================
|
|
// Clock actions
|
|
// =================================================================
|
|
|
|
async onClockAction() {
|
|
if (this.state.isCheckedIn) {
|
|
this.dropdown.close();
|
|
this.state.showClockoutConfirm = true;
|
|
return;
|
|
}
|
|
await this._executeClockAction();
|
|
}
|
|
|
|
async confirmClockOut() {
|
|
this.state.showClockoutConfirm = false;
|
|
await this._executeClockAction();
|
|
}
|
|
|
|
cancelClockOut() {
|
|
this.state.showClockoutConfirm = false;
|
|
}
|
|
|
|
async _executeClockAction() {
|
|
this.state.loading = true;
|
|
this.state.error = "";
|
|
|
|
try {
|
|
let lat = 0, lng = 0, acc = 0;
|
|
if (navigator.geolocation) {
|
|
try {
|
|
const pos = await new Promise((resolve, reject) => {
|
|
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
|
enableHighAccuracy: true,
|
|
timeout: 15000,
|
|
maximumAge: 0,
|
|
});
|
|
});
|
|
lat = pos.coords.latitude;
|
|
lng = pos.coords.longitude;
|
|
acc = pos.coords.accuracy;
|
|
} catch {
|
|
// Native GPS unavailable (common on desktops) -- try IP geolocation
|
|
}
|
|
}
|
|
if (lat === 0 && lng === 0) {
|
|
try {
|
|
const ipResp = await fetch("https://ipapi.co/json/");
|
|
if (ipResp.ok) {
|
|
const ipData = await ipResp.json();
|
|
if (ipData.latitude && ipData.longitude) {
|
|
lat = ipData.latitude;
|
|
lng = ipData.longitude;
|
|
acc = 5000;
|
|
}
|
|
}
|
|
} catch {
|
|
// IP geolocation also unavailable
|
|
}
|
|
}
|
|
|
|
const result = await rpc("/fusion_clock/clock_action", {
|
|
latitude: lat,
|
|
longitude: lng,
|
|
accuracy: acc,
|
|
source: "backend_fab",
|
|
});
|
|
|
|
if (result.requires_reason) {
|
|
this.state.loading = false;
|
|
this.dropdown.close();
|
|
this.state.showReasonDialog = true;
|
|
this.state.reasonText = "";
|
|
this.state.reasonTime = "";
|
|
return;
|
|
}
|
|
|
|
if (result.error) {
|
|
this.state.error = result.error;
|
|
this.state.loading = false;
|
|
return;
|
|
}
|
|
|
|
if (result.action === "clock_in") {
|
|
this.state.isCheckedIn = true;
|
|
this.state.checkInTime = new Date(result.check_in + "Z");
|
|
this.state.locationName = result.location_name || "";
|
|
this._startTimer();
|
|
this.notification.add(result.message || "Clocked in!", { type: "success" });
|
|
} else if (result.action === "clock_out") {
|
|
this.state.isCheckedIn = false;
|
|
this.state.checkInTime = null;
|
|
this._stopTimer();
|
|
this.state.timerDisplay = "00:00:00";
|
|
this.notification.add(result.message || "Clocked out!", { type: "success" });
|
|
await this._fetchStatus();
|
|
}
|
|
} catch (e) {
|
|
this.state.error = e.message || "Clock action failed.";
|
|
}
|
|
|
|
this.state.loading = false;
|
|
}
|
|
|
|
// =================================================================
|
|
// Reason dialog
|
|
// =================================================================
|
|
|
|
onReasonTextInput(ev) {
|
|
this.state.reasonText = ev.target.value;
|
|
}
|
|
|
|
onReasonTimeInput(ev) {
|
|
this.state.reasonTime = ev.target.value;
|
|
}
|
|
|
|
cancelReason() {
|
|
this.state.showReasonDialog = false;
|
|
this.state.reasonText = "";
|
|
this.state.reasonTime = "";
|
|
}
|
|
|
|
async submitReason() {
|
|
if (!this.state.reasonText.trim()) {
|
|
this.state.error = "Please provide a reason.";
|
|
return;
|
|
}
|
|
this.state.reasonSubmitting = true;
|
|
try {
|
|
await rpc("/fusion_clock/submit_reason", {
|
|
reason: this.state.reasonText.trim(),
|
|
departure_time: this.state.reasonTime || "",
|
|
});
|
|
this.state.showReasonDialog = false;
|
|
this.state.reasonText = "";
|
|
this.state.reasonTime = "";
|
|
this.state.reasonSubmitting = false;
|
|
this.notification.add("Reason submitted. You can now clock in.", { type: "success" });
|
|
} catch (e) {
|
|
this.state.error = "Failed to submit reason.";
|
|
this.state.reasonSubmitting = false;
|
|
}
|
|
}
|
|
|
|
// =================================================================
|
|
// Timer
|
|
// =================================================================
|
|
|
|
get confirmCheckinDisplay() {
|
|
if (!this.state.checkInTime) return "--";
|
|
const d = this.state.checkInTime;
|
|
let h = d.getHours();
|
|
const m = d.getMinutes();
|
|
const ampm = h >= 12 ? "PM" : "AM";
|
|
h = h % 12 || 12;
|
|
return h + ":" + (m < 10 ? "0" : "") + m + " " + ampm;
|
|
}
|
|
|
|
get confirmDurationDisplay() {
|
|
if (!this.state.checkInTime) return "--";
|
|
const diff = Math.max(0, Math.floor((new Date() - this.state.checkInTime) / 1000));
|
|
const dh = Math.floor(diff / 3600);
|
|
const dm = Math.floor((diff % 3600) / 60);
|
|
return dh + "h " + dm + "m";
|
|
}
|
|
|
|
_startTimer() {
|
|
this._stopTimer();
|
|
this._updateTimer();
|
|
this._timerInterval = setInterval(() => this._updateTimer(), 1000);
|
|
}
|
|
|
|
_stopTimer() {
|
|
if (this._timerInterval) {
|
|
clearInterval(this._timerInterval);
|
|
this._timerInterval = null;
|
|
}
|
|
}
|
|
|
|
_updateTimer() {
|
|
if (!this.state.checkInTime) return;
|
|
const now = new Date();
|
|
let diff = Math.max(0, Math.floor((now - this.state.checkInTime) / 1000));
|
|
const h = Math.floor(diff / 3600);
|
|
const m = Math.floor((diff % 3600) / 60);
|
|
const s = diff % 60;
|
|
const pad = (n) => (n < 10 ? "0" + n : "" + n);
|
|
this.state.timerDisplay = pad(h) + ":" + pad(m) + ":" + pad(s);
|
|
}
|
|
}
|
|
|
|
const systrayRegistry = registry.category("systray");
|
|
if (systrayRegistry.contains("hr_attendance.attendance_menu")) {
|
|
systrayRegistry.remove("hr_attendance.attendance_menu");
|
|
}
|
|
|
|
registry.category("systray").add("fusion_clock.ClockSystray", {
|
|
Component: FusionClockFAB,
|
|
}, { sequence: 101 });
|