/** @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 });