/** @odoo-module **/ /** * Fusion Clock - Portal Clock-In/Out Interaction (Odoo 19) * * Handles: GPS verification, clock in/out actions, live timer, * sound effects, persistent state, location selection, and UI animations. */ import { Interaction } from "@web/public/interaction"; import { registry } from "@web/core/registry"; import { rpc } from "@web/core/network/rpc"; export class FusionClockPortal extends Interaction { static selector = "#fusion-clock-app"; setup() { this.isCheckedIn = this.el.dataset.checkedIn === "true"; this.enableSounds = this.el.dataset.enableSounds === "true"; this.checkInTime = null; this.timerInterval = null; this.selectedLocationId = null; if (this.el.dataset.checkInTime) { this.checkInTime = new Date(this.el.dataset.checkInTime + "Z"); } // If server didn't provide check-in data, try localStorage if (!this.isCheckedIn || !this.checkInTime) { this._restoreState(); } else { this._saveState(); } // Load locations const locDataEl = document.getElementById("fclk-locations-data"); this.locations = []; if (locDataEl) { try { this.locations = JSON.parse(locDataEl.textContent); } catch (e) {} } if (this.locations.length > 0) { this.selectedLocationId = this.locations[0].id; this._restoreSelectedLocation(); } // Start live clock this._updateCurrentTime(); this.clockInterval = setInterval(() => this._updateCurrentTime(), 1000); // Start timer if checked in if (this.isCheckedIn && this.checkInTime) { this._startTimer(); this._updateUIForClockIn({ location_name: this.el.dataset.locationName || "", location_address: this.el.dataset.locationAddress || "", }); const locId = parseInt(this.el.dataset.locationId || "0"); if (locId) { this.selectedLocationId = locId; this._saveSelectedLocation(locId); } } else if (this.locations.length > 1) { this._detectNearestLocation(); } this._updateDateDisplay(); // Event listeners this._setupEventListeners(); // Visibility sync -- also sync on load to verify with server this._onVisibilityChange = () => this._syncOnVisibilityChange(); document.addEventListener("visibilitychange", this._onVisibilityChange); this._syncTimeout = setTimeout(() => this._syncOnVisibilityChange(), 3000); } destroy() { this._stopTimer(); if (this.clockInterval) clearInterval(this.clockInterval); if (this._syncTimeout) clearTimeout(this._syncTimeout); if (this._onVisibilityChange) { document.removeEventListener("visibilitychange", this._onVisibilityChange); } } _setupEventListeners() { const clockBtn = document.getElementById("fclk-clock-btn"); if (clockBtn) { this._onClockClick = (e) => this._onClockButtonClick(e); clockBtn.addEventListener("click", this._onClockClick); } const locationCard = document.getElementById("fclk-location-card"); if (locationCard && this.locations.length > 1) { locationCard.addEventListener("click", () => { const modal = document.getElementById("fclk-location-modal"); if (modal) modal.style.display = "flex"; }); } const reasonSubmitBtn = document.getElementById("fclk-reason-submit"); if (reasonSubmitBtn) { reasonSubmitBtn.addEventListener("click", () => this._submitReason()); } const leaveBtn = document.getElementById("fclk-leave-btn"); if (leaveBtn) { leaveBtn.addEventListener("click", () => { const modal = document.getElementById("fclk-leave-modal"); if (modal) modal.style.display = "flex"; }); } const leaveSubmitBtn = document.getElementById("fclk-leave-submit"); if (leaveSubmitBtn) { leaveSubmitBtn.addEventListener("click", () => this._submitLeave()); } const clockoutConfirmBtn = document.getElementById("fclk-clockout-confirm-btn"); if (clockoutConfirmBtn) { clockoutConfirmBtn.addEventListener("click", () => this._confirmClockOut()); } document.querySelectorAll("[data-dismiss]").forEach((btn) => { btn.addEventListener("click", () => { const targetId = btn.dataset.dismiss; const modal = document.getElementById(targetId); if (modal) modal.style.display = "none"; }); }); document.querySelectorAll(".fclk-modal-item").forEach((item) => { item.addEventListener("click", () => { const locId = parseInt(item.dataset.id); this.selectedLocationId = locId; this._saveSelectedLocation(locId); const nameEl = document.getElementById("fclk-location-name"); const addrEl = document.getElementById("fclk-location-address"); if (nameEl) nameEl.textContent = item.dataset.name; if (addrEl) addrEl.textContent = item.dataset.address; const modal = document.getElementById("fclk-location-modal"); if (modal) modal.style.display = "none"; }); }); } // ========================================================================= // Nearest Location Detection // ========================================================================= _detectNearestLocation() { if (!navigator.geolocation) return; navigator.geolocation.getCurrentPosition( (pos) => this._selectNearestFromCoords(pos.coords.latitude, pos.coords.longitude), async () => { try { const resp = await fetch("https://ipapi.co/json/"); if (resp.ok) { const data = await resp.json(); if (data.latitude && data.longitude) { this._selectNearestFromCoords(data.latitude, data.longitude); } } } catch { /* silent */ } }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 } ); } _selectNearestFromCoords(lat, lng) { let nearest = null; let minDist = Infinity; for (const loc of this.locations) { if (!loc.latitude || !loc.longitude) continue; const dist = this._haversine(lat, lng, loc.latitude, loc.longitude); if (dist < minDist) { minDist = dist; nearest = loc; } } if (!nearest) return; this.selectedLocationId = nearest.id; const nameEl = document.getElementById("fclk-location-name"); const addrEl = document.getElementById("fclk-location-address"); if (nameEl) nameEl.textContent = nearest.name; if (addrEl) addrEl.textContent = nearest.address || ""; } _haversine(lat1, lon1, lat2, lon2) { const R = 6371000; const toRad = (v) => (v * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } // ========================================================================= // Clock Action // ========================================================================= _onClockButtonClick(e) { e.preventDefault(); const btn = document.getElementById("fclk-clock-btn"); if (!btn || btn.disabled) return; if (this.isCheckedIn) { this._showClockOutConfirmation(); return; } this._beginClockAction(); } _showClockOutConfirmation() { const modal = document.getElementById("fclk-clockout-confirm-modal"); if (!modal) { this._beginClockAction(); return; } const checkinEl = document.getElementById("fclk-confirm-checkin-time"); const durationEl = document.getElementById("fclk-confirm-duration"); if (checkinEl && this.checkInTime) { const h = this.checkInTime.getHours(); const m = this.checkInTime.getMinutes(); const ampm = h >= 12 ? "PM" : "AM"; const hour12 = h % 12 || 12; checkinEl.textContent = hour12 + ":" + (m < 10 ? "0" : "") + m + " " + ampm; } if (durationEl && this.checkInTime) { const diff = Math.max(0, Math.floor((new Date() - this.checkInTime) / 1000)); const dh = Math.floor(diff / 3600); const dm = Math.floor((diff % 3600) / 60); durationEl.textContent = dh + "h " + dm + "m"; } modal.style.display = "flex"; } _confirmClockOut() { const modal = document.getElementById("fclk-clockout-confirm-modal"); if (modal) modal.style.display = "none"; this._beginClockAction(); } _beginClockAction() { const btn = document.getElementById("fclk-clock-btn"); if (!btn || btn.disabled) return; btn.disabled = true; const ripple = btn.querySelector(".fclk-btn-ripple"); if (ripple) { ripple.classList.remove("fclk-ripple-active"); void ripple.offsetWidth; ripple.classList.add("fclk-ripple-active"); } this._showGPSOverlay(); if (!navigator.geolocation) { this._hideGPSOverlay(); this._showToast("Geolocation is not supported by your browser.", "error"); btn.disabled = false; return; } navigator.geolocation.getCurrentPosition( (pos) => { this._performClockAction(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy); }, async () => { let 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; } } } catch { // IP geolocation also unavailable } this._hideGPSOverlay(); this._performClockAction(lat, lng, lat ? 5000 : 0); }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 } ); } async _performClockAction(lat, lng, accuracy) { const btn = document.getElementById("fclk-clock-btn"); try { const result = await rpc("/fusion_clock/clock_action", { latitude: lat, longitude: lng, accuracy: accuracy, source: "portal", }); this._hideGPSOverlay(); if (btn) btn.disabled = false; if (result.requires_reason) { this._showReasonModal(); return; } if (result.error) { this._showToast(result.error, "error"); this._shakeButton(); return; } if (result.action === "clock_in") { this.isCheckedIn = true; this.checkInTime = new Date(result.check_in + "Z"); this.el.dataset.locationName = result.location_name || ""; this._updateUIForClockIn(result); this._startTimer(); this._playSound("in"); this._showToast(result.message, "success"); this._saveState(); if (this.selectedLocationId) this._saveSelectedLocation(this.selectedLocationId); } else if (result.action === "clock_out") { this.isCheckedIn = false; this._updateUIForClockOut(result); this._stopTimer(); this._playSound("out"); this._showToast(result.message, "success"); this._clearState(); } } catch (err) { this._hideGPSOverlay(); this._showToast("Network error. Please try again.", "error"); if (btn) btn.disabled = false; } } // ========================================================================= // UI Updates // ========================================================================= _updateUIForClockIn(data) { const dot = document.getElementById("fclk-status-dot"); const statusText = document.getElementById("fclk-status-text"); const timerLabel = document.getElementById("fclk-timer-label"); const btnLabel = document.getElementById("fclk-btn-label"); const btn = document.getElementById("fclk-clock-btn"); const playIcon = document.getElementById("fclk-btn-icon-play"); const stopIcon = document.getElementById("fclk-btn-icon-stop"); if (dot) dot.classList.add("fclk-dot-active"); if (statusText) statusText.textContent = "Clocked In"; if (timerLabel) timerLabel.textContent = "Time Elapsed"; if (btnLabel) btnLabel.textContent = "Tap to Clock Out"; if (btn) btn.classList.add("fclk-clock-btn-out"); if (playIcon) playIcon.style.display = "none"; if (stopIcon) stopIcon.style.display = "block"; if (data.location_name) { const locEl = document.getElementById("fclk-location-name"); if (locEl) locEl.textContent = data.location_name; } if (data.location_address) { const addrEl = document.getElementById("fclk-location-address"); if (addrEl) addrEl.textContent = data.location_address; } } _updateUIForClockOut(data) { const dot = document.getElementById("fclk-status-dot"); const statusText = document.getElementById("fclk-status-text"); const timerLabel = document.getElementById("fclk-timer-label"); const btnLabel = document.getElementById("fclk-btn-label"); const btn = document.getElementById("fclk-clock-btn"); const playIcon = document.getElementById("fclk-btn-icon-play"); const stopIcon = document.getElementById("fclk-btn-icon-stop"); const timer = document.getElementById("fclk-timer"); if (dot) dot.classList.remove("fclk-dot-active"); if (statusText) statusText.textContent = "Not Clocked In"; if (timerLabel) timerLabel.textContent = "Ready to Clock In"; if (btnLabel) btnLabel.textContent = "Tap to Clock In"; if (btn) btn.classList.remove("fclk-clock-btn-out"); if (playIcon) playIcon.style.display = "block"; if (stopIcon) stopIcon.style.display = "none"; if (timer) timer.textContent = "00 : 00 : 00"; if (data.net_hours !== undefined) { const todayEl = document.getElementById("fclk-today-hours"); if (todayEl) { const current = parseFloat(todayEl.textContent) || 0; todayEl.textContent = (current + data.net_hours).toFixed(1) + "h"; } const weekEl = document.getElementById("fclk-week-hours"); if (weekEl) { const currentW = parseFloat(weekEl.textContent) || 0; weekEl.textContent = (currentW + data.net_hours).toFixed(1) + "h"; } } } // ========================================================================= // Timer // ========================================================================= _startTimer() { this._stopTimer(); this._updateTimer(); this.timerInterval = setInterval(() => this._updateTimer(), 1000); } _stopTimer() { if (this.timerInterval) { clearInterval(this.timerInterval); this.timerInterval = null; } } _updateTimer() { if (!this.checkInTime) return; const now = new Date(); let diff = Math.max(0, Math.floor((now - this.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); const timerEl = document.getElementById("fclk-timer"); if (timerEl) { timerEl.textContent = pad(h) + " : " + pad(m) + " : " + pad(s); } } // ========================================================================= // Date & Time Display // ========================================================================= _updateDateDisplay() { const el = document.getElementById("fclk-date-display"); if (!el) return; const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; const now = new Date(); el.textContent = days[now.getDay()] + ", " + months[now.getMonth()] + " " + now.getDate(); } _updateCurrentTime() { const el = document.getElementById("fclk-current-time"); if (!el) return; const now = new Date(); let h = now.getHours(); const m = now.getMinutes(); const ampm = h >= 12 ? "PM" : "AM"; h = h % 12 || 12; el.textContent = h + ":" + (m < 10 ? "0" : "") + m + " " + ampm; } // ========================================================================= // Sound Effects // ========================================================================= _playSound(type) { if (!this.enableSounds) return; try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); if (type === "in") { osc.type = "sine"; osc.frequency.setValueAtTime(523, ctx.currentTime); osc.frequency.setValueAtTime(659, ctx.currentTime + 0.1); osc.frequency.setValueAtTime(784, ctx.currentTime + 0.2); gain.gain.setValueAtTime(0.3, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5); osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.5); } else { osc.type = "sine"; osc.frequency.setValueAtTime(784, ctx.currentTime); osc.frequency.setValueAtTime(523, ctx.currentTime + 0.15); gain.gain.setValueAtTime(0.25, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4); osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.4); } } catch (e) { // Sounds are non-critical } } // ========================================================================= // Toast Notifications // ========================================================================= _showToast(msg, type) { const toast = document.getElementById("fclk-toast"); const toastMsg = document.getElementById("fclk-toast-msg"); const toastIcon = document.getElementById("fclk-toast-icon"); if (!toast) return; toast.className = "fclk-toast fclk-toast-" + (type || "success"); if (toastMsg) toastMsg.textContent = msg; if (toastIcon) { toastIcon.innerHTML = type === "error" ? '' : ''; } toast.style.display = "flex"; toast.style.animation = "fclk-toast-in 0.3s ease-out"; setTimeout(() => { toast.style.animation = "fclk-toast-out 0.3s ease-in forwards"; setTimeout(() => { toast.style.display = "none"; }, 300); }, 3000); } // ========================================================================= // GPS Overlay // ========================================================================= _showGPSOverlay() { const el = document.getElementById("fclk-gps-overlay"); if (el) el.style.display = "flex"; } _hideGPSOverlay() { const el = document.getElementById("fclk-gps-overlay"); if (el) el.style.display = "none"; } // ========================================================================= // Shake Animation // ========================================================================= _shakeButton() { const btn = document.getElementById("fclk-clock-btn"); if (!btn) return; btn.classList.add("fclk-shake"); setTimeout(() => { btn.classList.remove("fclk-shake"); }, 500); } // ========================================================================= // Persistent State // ========================================================================= _saveState() { try { localStorage.setItem("fclk_checked_in", "true"); if (this.checkInTime) { localStorage.setItem("fclk_check_in_time", this.checkInTime.toISOString()); } if (this.el.dataset.locationName) { localStorage.setItem("fclk_location_name", this.el.dataset.locationName); } } catch (e) {} } _clearState() { try { localStorage.removeItem("fclk_checked_in"); localStorage.removeItem("fclk_check_in_time"); localStorage.removeItem("fclk_location_name"); } catch (e) {} } _restoreState() { try { const wasCheckedIn = localStorage.getItem("fclk_checked_in") === "true"; const savedTime = localStorage.getItem("fclk_check_in_time"); if (wasCheckedIn && savedTime) { const t = new Date(savedTime); if (!isNaN(t.getTime()) && (Date.now() - t.getTime()) < 24 * 60 * 60 * 1000) { this.isCheckedIn = true; this.checkInTime = t; this.el.dataset.locationName = localStorage.getItem("fclk_location_name") || ""; } } } catch (e) {} } _saveSelectedLocation(locId) { try { localStorage.setItem("fclk_selected_location", String(locId)); } catch (e) {} } _restoreSelectedLocation() { try { const saved = localStorage.getItem("fclk_selected_location"); if (!saved) return; const savedId = parseInt(saved); const loc = this.locations.find((l) => l.id === savedId); if (!loc) return; this.selectedLocationId = savedId; const nameEl = document.getElementById("fclk-location-name"); const addrEl = document.getElementById("fclk-location-address"); if (nameEl) nameEl.textContent = loc.name; if (addrEl) addrEl.textContent = loc.address || ""; } catch (e) {} } // ========================================================================= // Reason Modal & Leave Request // ========================================================================= _showReasonModal() { const modal = document.getElementById("fclk-reason-modal"); if (modal) modal.style.display = "flex"; } async _submitReason() { const reasonEl = document.getElementById("fclk-reason-text"); const timeEl = document.getElementById("fclk-reason-time"); const reason = reasonEl ? reasonEl.value.trim() : ""; const rawTime = timeEl ? timeEl.value.trim() : ""; const depTime = rawTime ? new Date(rawTime).toISOString() : ""; if (!reason) { this._showToast("Please provide a reason.", "error"); return; } try { const result = await rpc("/fusion_clock/submit_reason", { reason: reason, departure_time: depTime, }); if (result.success) { const modal = document.getElementById("fclk-reason-modal"); if (modal) modal.style.display = "none"; this._showToast(result.message, "success"); if (reasonEl) reasonEl.value = ""; if (timeEl) timeEl.value = ""; } else { this._showToast(result.error || "Failed to submit.", "error"); } } catch (e) { this._showToast("Network error.", "error"); } } async _submitLeave() { const fromEl = document.getElementById("fclk-leave-date"); const toEl = document.getElementById("fclk-leave-date-to"); const reasonEl = document.getElementById("fclk-leave-reason"); const dateFrom = fromEl ? fromEl.value : ""; let dateTo = toEl && toEl.value ? toEl.value : dateFrom; // single day if "To" left blank const reason = reasonEl ? reasonEl.value.trim() : ""; if (!dateFrom || !reason) { this._showToast("Please provide a start date and a reason.", "error"); return; } if (dateTo < dateFrom) { this._showToast("End date can't be before the start date.", "error"); return; } try { const result = await rpc("/fusion_clock/request_leave", { date_from: dateFrom, date_to: dateTo, reason: reason, }); if (result.success) { const modal = document.getElementById("fclk-leave-modal"); if (modal) modal.style.display = "none"; this._showToast(result.message, "success"); if (fromEl) fromEl.value = ""; if (toEl) toEl.value = ""; if (reasonEl) reasonEl.value = ""; } else { this._showToast(result.error || "Failed to submit.", "error"); } } catch (e) { this._showToast("Network error.", "error"); } } // ========================================================================= // Sync on visibility change // ========================================================================= async _syncOnVisibilityChange() { if (document.visibilityState !== "visible") return; try { const result = await rpc("/fusion_clock/get_status", {}); if (result.error) return; if (result.is_checked_in && !this.isCheckedIn) { this.isCheckedIn = true; this.checkInTime = new Date(result.check_in + "Z"); this._updateUIForClockIn({ location_name: result.location_name, location_address: result.location_address || "" }); this._startTimer(); this._saveState(); } else if (result.is_checked_in && this.isCheckedIn) { const serverTime = new Date(result.check_in + "Z"); if (Math.abs(serverTime - this.checkInTime) > 5000) { this.checkInTime = serverTime; this._saveState(); } } else if (!result.is_checked_in && this.isCheckedIn) { this.isCheckedIn = false; this._updateUIForClockOut({}); this._stopTimer(); this._clearState(); } const todayEl = document.getElementById("fclk-today-hours"); if (todayEl && result.today_hours !== undefined) { todayEl.textContent = result.today_hours.toFixed(1) + "h"; } const weekEl = document.getElementById("fclk-week-hours"); if (weekEl && result.week_hours !== undefined) { weekEl.textContent = result.week_hours.toFixed(1) + "h"; } } catch { // Server unavailable (restarting) -- keep timer running from localStorage } } } registry.category("public.interactions").add("fusion_clock.portal", FusionClockPortal);