Files
Odoo-Modules/fusion_clock/static/src/js/fusion_clock_systray.js
gsinghpal 78fa8f07ee fix(fusion_clock): stop stale missed-clock-in nag; add Owner role + attendance exemption
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>
2026-06-02 17:54:00 -04:00

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 });