Initial commit
This commit is contained in:
502
fusion_clock/static/src/js/fusion_clock_portal.js
Normal file
502
fusion_clock/static/src/js/fusion_clock_portal.js
Normal file
@@ -0,0 +1,502 @@
|
||||
/** @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");
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Auto-detect nearest location in background
|
||||
this._autoSelectNearestLocation();
|
||||
|
||||
// Restore localStorage state
|
||||
this._restoreState();
|
||||
|
||||
// Start live clock
|
||||
this._updateCurrentTime();
|
||||
this.clockInterval = setInterval(() => this._updateCurrentTime(), 1000);
|
||||
|
||||
// Start timer if checked in
|
||||
if (this.isCheckedIn && this.checkInTime) {
|
||||
this._startTimer();
|
||||
}
|
||||
|
||||
this._updateDateDisplay();
|
||||
|
||||
// Event listeners
|
||||
this._setupEventListeners();
|
||||
|
||||
// Visibility sync
|
||||
this._onVisibilityChange = () => this._syncOnVisibilityChange();
|
||||
document.addEventListener("visibilitychange", this._onVisibilityChange);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._stopTimer();
|
||||
if (this.clockInterval) clearInterval(this.clockInterval);
|
||||
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";
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll(".fclk-modal-item").forEach((item) => {
|
||||
item.addEventListener("click", () => {
|
||||
this.selectedLocationId = parseInt(item.dataset.id);
|
||||
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";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Clock Action
|
||||
// =========================================================================
|
||||
|
||||
_onClockButtonClick(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById("fclk-clock-btn");
|
||||
if (!btn || btn.disabled) return;
|
||||
btn.disabled = true;
|
||||
|
||||
// Ripple effect
|
||||
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);
|
||||
},
|
||||
(err) => {
|
||||
this._hideGPSOverlay();
|
||||
let msg = "Could not get your location. ";
|
||||
if (err.code === 1) msg += "Please allow location access.";
|
||||
else if (err.code === 2) msg += "Location unavailable.";
|
||||
else if (err.code === 3) msg += "Location request timed out.";
|
||||
this._showToast(msg, "error");
|
||||
this._shakeButton();
|
||||
btn.disabled = false;
|
||||
},
|
||||
{ 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.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._updateUIForClockIn(result);
|
||||
this._startTimer();
|
||||
this._playSound("in");
|
||||
this._showToast(result.message, "success");
|
||||
this._saveState();
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
_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;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Auto-detect nearest location
|
||||
// =========================================================================
|
||||
|
||||
_autoSelectNearestLocation() {
|
||||
if (this.locations.length < 1) return;
|
||||
if (!navigator.geolocation) return;
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const userLat = pos.coords.latitude;
|
||||
const userLng = pos.coords.longitude;
|
||||
let nearest = this.locations[0];
|
||||
let minDist = Infinity;
|
||||
|
||||
for (const loc of this.locations) {
|
||||
const d = this._haversine(userLat, userLng, loc.latitude, loc.longitude);
|
||||
if (d < minDist) {
|
||||
minDist = d;
|
||||
nearest = loc;
|
||||
}
|
||||
}
|
||||
|
||||
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 || "";
|
||||
},
|
||||
() => {
|
||||
// Silently fall back to the first location
|
||||
},
|
||||
{ enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 }
|
||||
);
|
||||
}
|
||||
|
||||
_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) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 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"
|
||||
? '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>'
|
||||
: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#10B981" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>';
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
_clearState() {
|
||||
try {
|
||||
localStorage.removeItem("fclk_checked_in");
|
||||
localStorage.removeItem("fclk_check_in_time");
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
_restoreState() {
|
||||
try {
|
||||
if (!this.isCheckedIn && localStorage.getItem("fclk_checked_in") === "true") {
|
||||
this._clearState();
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 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 });
|
||||
this._startTimer();
|
||||
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 (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("public.interactions").add("fusion_clock.portal", FusionClockPortal);
|
||||
Reference in New Issue
Block a user