Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View File

@@ -0,0 +1,300 @@
/** @odoo-module **/
/**
* Fusion Clock - Location Widgets (Odoo 19)
*
* 1) fclk_places_address - CharField with Google Places Autocomplete.
* Selecting a place auto-fills lat/lng on the record (no manual geocode).
*
* 2) fclk_map - Interactive map view widget.
* Reacts live to lat/lng/radius changes on the record.
* Dragging the pin updates the record directly (no separate save).
*/
import { Component, onMounted, onWillUnmount, useRef, useState, useEffect } from "@odoo/owl";
import { CharField, charField } from "@web/views/fields/char/char_field";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
// ---------------------------------------------------------------------------
// Google Maps JS API singleton loader
// ---------------------------------------------------------------------------
let _gmapsPromise = null;
function loadGoogleMapsAPI(apiKey) {
if (window.google?.maps?.places) {
return Promise.resolve();
}
if (_gmapsPromise) {
return _gmapsPromise;
}
_gmapsPromise = new Promise((resolve, reject) => {
window.__fclkGmapsReady = () => {
delete window.__fclkGmapsReady;
resolve();
};
const s = document.createElement("script");
s.src =
"https://maps.googleapis.com/maps/api/js?key=" +
encodeURIComponent(apiKey) +
"&libraries=places&callback=__fclkGmapsReady";
s.async = true;
s.onerror = () => reject(new Error("Failed to load Google Maps JS API"));
document.head.appendChild(s);
});
return _gmapsPromise;
}
// ===========================================================================
// 1) fclk_map - Interactive map view widget
// ===========================================================================
export class FclkLocationMap extends Component {
static template = "fusion_clock.LocationMap";
static props = { ...standardWidgetProps };
setup() {
this.mapContainerRef = useRef("mapContainer");
this.searchRef = useRef("searchInput");
this.notification = useService("notification");
this.state = useState({
loaded: false,
error: null,
});
this.map = null;
this.marker = null;
this.circle = null;
this._skipSync = false;
onMounted(() => this._init());
onWillUnmount(() => this._cleanup());
// React to record data changes (lat, lng, radius)
useEffect(
() => {
this._syncMapToRecord();
},
() => {
const d = this.props.record.data;
return [d.latitude, d.longitude, d.radius];
}
);
}
get record() {
return this.props.record;
}
// ------------------------------------------------------------------
// Initialise
// ------------------------------------------------------------------
async _init() {
try {
const settings = await rpc("/fusion_clock/get_settings");
const apiKey = settings?.google_maps_api_key;
if (!apiKey) {
this.state.error =
"Google Maps API key not configured. Go to Fusion Clock Settings.";
return;
}
await loadGoogleMapsAPI(apiKey);
this.state.loaded = true;
await new Promise((r) => requestAnimationFrame(r));
this._buildMap();
} catch (e) {
this.state.error = "Could not load Google Maps: " + (e.message || e);
}
}
// ------------------------------------------------------------------
// Build the map + marker + circle + search
// ------------------------------------------------------------------
_buildMap() {
const lat = this.record.data.latitude || 43.65;
const lng = this.record.data.longitude || -79.38;
const radius = this.record.data.radius || 100;
const hasCoords =
this.record.data.latitude !== 0 || this.record.data.longitude !== 0;
const el = this.mapContainerRef.el;
if (!el) return;
this.map = new google.maps.Map(el, {
center: { lat, lng },
zoom: hasCoords ? 17 : 12,
mapTypeControl: true,
streetViewControl: false,
fullscreenControl: true,
});
this.marker = new google.maps.Marker({
position: { lat, lng },
map: this.map,
draggable: !this.props.readonly,
animation: google.maps.Animation.DROP,
title: "Drag to fine-tune location",
});
this.circle = new google.maps.Circle({
map: this.map,
center: { lat, lng },
radius,
fillColor: "#10B981",
fillOpacity: 0.12,
strokeColor: "#10B981",
strokeWeight: 2,
strokeOpacity: 0.45,
});
// Pin drag -> update record directly (saves with form Save button)
this.marker.addListener("dragend", () => {
const pos = this.marker.getPosition();
this.circle.setCenter(pos);
this._skipSync = true;
this.record.update({
latitude: pos.lat(),
longitude: pos.lng(),
});
});
// Places Autocomplete search inside the map widget
if (this.searchRef.el && !this.props.readonly) {
const autocomplete = new google.maps.places.Autocomplete(
this.searchRef.el,
{ types: ["establishment", "geocode"] }
);
autocomplete.bindTo("bounds", this.map);
autocomplete.addListener("place_changed", () => {
const place = autocomplete.getPlace();
if (!place.geometry?.location) return;
const loc = place.geometry.location;
this.map.setCenter(loc);
this.map.setZoom(17);
this.marker.setPosition(loc);
this.circle.setCenter(loc);
this._skipSync = true;
const updates = {
latitude: loc.lat(),
longitude: loc.lng(),
};
if (place.formatted_address) {
updates.address = place.formatted_address;
}
this.record.update(updates);
});
}
}
// ------------------------------------------------------------------
// Sync map when record lat/lng/radius changes externally
// (e.g. from the address field's Places autocomplete)
// ------------------------------------------------------------------
_syncMapToRecord() {
if (!this.map || !this.marker || !this.circle) return;
// Skip if this change originated from our own map interaction
if (this._skipSync) {
this._skipSync = false;
return;
}
const lat = this.record.data.latitude || 43.65;
const lng = this.record.data.longitude || -79.38;
const radius = this.record.data.radius || 100;
const hasCoords =
this.record.data.latitude !== 0 || this.record.data.longitude !== 0;
const pos = { lat, lng };
this.map.setCenter(pos);
if (hasCoords) {
this.map.setZoom(17);
}
this.marker.setPosition(pos);
this.circle.setCenter(pos);
this.circle.setRadius(radius);
}
// ------------------------------------------------------------------
// Cleanup
// ------------------------------------------------------------------
_cleanup() {
this.map = null;
this.marker = null;
this.circle = null;
}
}
registry.category("view_widgets").add("fclk_map", {
component: FclkLocationMap,
});
// ===========================================================================
// 2) fclk_places_address - CharField with Google Places Autocomplete
// ===========================================================================
export class FclkPlacesAddress extends CharField {
static template = "fusion_clock.PlacesAddress";
setup() {
super.setup();
this._autocomplete = null;
onMounted(() => this._attachPlaces());
onWillUnmount(() => this._detachPlaces());
}
async _attachPlaces() {
if (this.props.readonly) return;
try {
const settings = await rpc("/fusion_clock/get_settings");
const apiKey = settings?.google_maps_api_key;
if (!apiKey) return;
await loadGoogleMapsAPI(apiKey);
const inputEl = this.input?.el;
if (!inputEl) return;
this._autocomplete = new google.maps.places.Autocomplete(inputEl, {
types: ["establishment", "geocode"],
});
this._autocomplete.addListener("place_changed", () => {
const place = this._autocomplete.getPlace();
if (!place.geometry?.location) return;
const lat = place.geometry.location.lat();
const lng = place.geometry.location.lng();
const addr = place.formatted_address || place.name || "";
// Auto-fill address + coordinates on the record
this.props.record.update({
[this.props.name]: addr,
latitude: lat,
longitude: lng,
});
});
} catch (e) {
// Silently degrade - Places is non-critical
}
}
_detachPlaces() {
if (this._autocomplete) {
google.maps.event.clearInstanceListeners(this._autocomplete);
this._autocomplete = null;
}
}
}
const fclkPlacesAddress = {
...charField,
component: FclkPlacesAddress,
};
registry.category("fields").add("fclk_places_address", fclkPlacesAddress);

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

View File

@@ -0,0 +1,330 @@
/** @odoo-module **/
/**
* Fusion Clock - Portal Floating Action Button (Odoo 19)
*
* A persistent clock-in/out FAB that appears on ALL portal pages.
* Uses the Interaction class pattern required by Odoo 19 frontend.
* Shares RPC endpoints with the backend FAB and full clock page.
*/
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
export class FusionClockPortalFAB extends Interaction {
static selector = "#fclk-portal-fab";
setup() {
// Hide on /my/clock page to avoid duplication with the full clock UI
if (window.location.pathname === "/my/clock") {
this.el.style.display = "none";
return;
}
this.isCheckedIn = this.el.dataset.checkedIn === "true";
this.checkInTime = null;
this.expanded = false;
this._timerInterval = null;
this._pollInterval = null;
if (this.el.dataset.checkInTime) {
this.checkInTime = new Date(this.el.dataset.checkInTime + "Z");
}
// Cache DOM references
this.fabBtn = this.el.querySelector(".fclk-pfab-btn");
this.panel = this.el.querySelector(".fclk-pfab-panel");
this.statusDot = this.el.querySelector(".fclk-pfab-status-dot");
this.statusText = this.el.querySelector(".fclk-pfab-status-text");
this.locationRow = this.el.querySelector(".fclk-pfab-location");
this.locationText = this.el.querySelector(".fclk-pfab-location-text");
this.timerEl = this.el.querySelector(".fclk-pfab-timer");
this.badgeEl = this.el.querySelector(".fclk-pfab-badge");
this.actionBtn = this.el.querySelector(".fclk-pfab-action");
this.errorEl = this.el.querySelector(".fclk-pfab-error");
this.errorText = this.el.querySelector(".fclk-pfab-error-text");
this.todayEl = this.el.querySelector(".fclk-pfab-today");
this.weekEl = this.el.querySelector(".fclk-pfab-week");
// Bind events
if (this.fabBtn) {
this._onFabClick = () => this._togglePanel();
this.fabBtn.addEventListener("click", this._onFabClick);
}
if (this.actionBtn) {
this._onActionClick = (e) => {
e.preventDefault();
this._onClockAction();
};
this.actionBtn.addEventListener("click", this._onActionClick);
}
// Close panel on outside click
this._onDocClick = (ev) => {
if (!this.expanded) return;
if (!ev.target.closest("#fclk-portal-fab")) {
this._closePanel();
}
};
document.addEventListener("click", this._onDocClick, true);
// Re-sync when tab gains focus
this._onFocus = () => this._fetchStatus();
window.addEventListener("focus", this._onFocus);
// Visibility change sync
this._onVisibility = () => {
if (document.visibilityState === "visible") this._fetchStatus();
};
document.addEventListener("visibilitychange", this._onVisibility);
// Initial state
this._applyState();
// Start timer if already checked in
if (this.isCheckedIn && this.checkInTime) {
this._startTimer();
}
// Poll every 30s to stay in sync
this._pollInterval = setInterval(() => this._fetchStatus(), 30000);
}
destroy() {
this._stopTimer();
if (this._pollInterval) clearInterval(this._pollInterval);
if (this._onDocClick) document.removeEventListener("click", this._onDocClick, true);
if (this._onFocus) window.removeEventListener("focus", this._onFocus);
if (this._onVisibility) document.removeEventListener("visibilitychange", this._onVisibility);
if (this.fabBtn && this._onFabClick) this.fabBtn.removeEventListener("click", this._onFabClick);
if (this.actionBtn && this._onActionClick) this.actionBtn.removeEventListener("click", this._onActionClick);
}
// =========================================================================
// Panel Toggle
// =========================================================================
_togglePanel() {
if (this.expanded) {
this._closePanel();
} else {
this._openPanel();
}
}
_openPanel() {
this.expanded = true;
if (this.panel) {
this.panel.style.display = "block";
this.panel.classList.add("fclk-pfab-panel--open");
}
this._clearError();
this._fetchStatus();
}
_closePanel() {
this.expanded = false;
if (this.panel) {
this.panel.classList.remove("fclk-pfab-panel--open");
setTimeout(() => {
if (!this.expanded && this.panel) this.panel.style.display = "none";
}, 200);
}
}
// =========================================================================
// State Management
// =========================================================================
_applyState() {
// FAB button state
if (this.fabBtn) {
this.fabBtn.classList.toggle("fclk-pfab-btn--active", this.isCheckedIn);
}
this.el.classList.toggle("fclk-pfab--active", this.isCheckedIn);
// Status dot and text
if (this.statusDot) {
this.statusDot.classList.toggle("fclk-pfab-status-dot--active", this.isCheckedIn);
}
if (this.statusText) {
this.statusText.textContent = this.isCheckedIn ? "Clocked In" : "Not Clocked In";
}
// Location
const locName = this.el.dataset.locationName || "";
if (this.locationRow) {
this.locationRow.style.display = (this.isCheckedIn && locName) ? "flex" : "none";
}
if (this.locationText) {
this.locationText.textContent = locName;
}
// Action button
if (this.actionBtn) {
if (this.isCheckedIn) {
this.actionBtn.classList.remove("fclk-pfab-action--in");
this.actionBtn.classList.add("fclk-pfab-action--out");
this.actionBtn.innerHTML = '<i class="fa fa-stop-circle-o"/> Clock Out';
} else {
this.actionBtn.classList.remove("fclk-pfab-action--out");
this.actionBtn.classList.add("fclk-pfab-action--in");
this.actionBtn.innerHTML = '<i class="fa fa-play-circle-o"/> Clock In';
}
}
// Badge
if (this.badgeEl) {
this.badgeEl.style.display = this.isCheckedIn ? "block" : "none";
}
// Timer reset when not checked in
if (!this.isCheckedIn && this.timerEl) {
this.timerEl.textContent = "00:00:00";
}
}
// =========================================================================
// Fetch Status from Server
// =========================================================================
async _fetchStatus() {
try {
const result = await rpc("/fusion_clock/get_status", {});
if (result.error) return;
const wasCheckedIn = this.isCheckedIn;
this.isCheckedIn = result.is_checked_in;
if (result.is_checked_in && result.check_in) {
this.checkInTime = new Date(result.check_in + "Z");
this.el.dataset.locationName = result.location_name || "";
if (!wasCheckedIn) this._startTimer();
} else {
this.checkInTime = null;
this._stopTimer();
}
if (this.todayEl) {
this.todayEl.textContent = (result.today_hours || 0).toFixed(1) + "h";
}
if (this.weekEl) {
this.weekEl.textContent = (result.week_hours || 0).toFixed(1) + "h";
}
this._applyState();
} catch (e) {
// Silent fail - will retry on next poll
}
}
// =========================================================================
// Clock Action
// =========================================================================
async _onClockAction() {
if (this.actionBtn) this.actionBtn.disabled = true;
this._clearError();
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 (geoErr) {
this._showError("Location access denied. Please enable GPS.");
if (this.actionBtn) this.actionBtn.disabled = false;
return;
}
}
const result = await rpc("/fusion_clock/clock_action", {
latitude: lat,
longitude: lng,
accuracy: acc,
source: "portal_fab",
});
if (result.error) {
this._showError(result.error);
if (this.actionBtn) this.actionBtn.disabled = false;
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._startTimer();
} else if (result.action === "clock_out") {
this.isCheckedIn = false;
this.checkInTime = null;
this._stopTimer();
await this._fetchStatus();
}
this._applyState();
} catch (e) {
this._showError("Network error. Please try again.");
}
if (this.actionBtn) this.actionBtn.disabled = false;
}
// =========================================================================
// 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 display = pad(h) + ":" + pad(m) + ":" + pad(s);
if (this.timerEl) this.timerEl.textContent = display;
if (this.badgeEl) this.badgeEl.textContent = display;
}
// =========================================================================
// Error Display
// =========================================================================
_showError(msg) {
if (this.errorEl) this.errorEl.style.display = "flex";
if (this.errorText) this.errorText.textContent = msg;
}
_clearError() {
if (this.errorEl) this.errorEl.style.display = "none";
if (this.errorText) this.errorText.textContent = "";
}
}
registry.category("public.interactions").add("fusion_clock.portal_fab", FusionClockPortalFAB);

View File

@@ -0,0 +1,183 @@
/** @odoo-module **/
import { Component, useState, onWillStart, onMounted, onWillUnmount } from "@odoo/owl";
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.ClockFAB";
setup() {
this.notification = useService("notification");
this.state = useState({
isCheckedIn: false,
isDisplayed: false,
expanded: false,
checkInTime: null,
locationName: "",
timerDisplay: "00:00:00",
todayHours: "0.0",
weekHours: "0.0",
loading: false,
error: "",
});
this._timerInterval = null;
onWillStart(async () => {
await this._fetchStatus();
});
onMounted(() => {
if (this.state.isCheckedIn) {
this._startTimer();
}
// Poll every 15s to stay in sync with portal clock-outs
this._pollInterval = setInterval(() => this._fetchStatus(), 15000);
// Re-sync immediately when browser tab regains focus
this._onFocus = () => this._fetchStatus();
window.addEventListener("focus", this._onFocus);
// Close panel when clicking outside
this._onDocClick = (ev) => {
if (!this.state.expanded) return;
const el = ev.target.closest(".fclk-fab-wrapper");
if (!el) this.state.expanded = false;
};
document.addEventListener("click", this._onDocClick, true);
});
onWillUnmount(() => {
this._stopTimer();
if (this._pollInterval) clearInterval(this._pollInterval);
if (this._onDocClick) document.removeEventListener("click", this._onDocClick, true);
if (this._onFocus) window.removeEventListener("focus", this._onFocus);
});
}
togglePanel() {
this.state.expanded = !this.state.expanded;
this.state.error = "";
// Always re-fetch when opening the panel
if (this.state.expanded) {
this._fetchStatus();
}
}
async _fetchStatus() {
try {
const result = await rpc("/fusion_clock/get_status", {});
if (result.error) {
this.state.isDisplayed = false;
return;
}
this.state.isDisplayed = result.enable_clock !== false;
this.state.isCheckedIn = result.is_checked_in;
this.state.locationName = result.location_name || "";
this.state.todayHours = (result.today_hours || 0).toFixed(1);
this.state.weekHours = (result.week_hours || 0).toFixed(1);
if (result.is_checked_in && result.check_in) {
this.state.checkInTime = new Date(result.check_in + "Z");
this._startTimer();
} else {
this.state.checkInTime = null;
this._stopTimer();
this.state.timerDisplay = "00:00:00";
}
} catch (e) {
this.state.isDisplayed = false;
}
}
async onClockAction() {
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 (geoErr) {
this.state.error = "Location access denied.";
this.state.loading = false;
return;
}
}
const result = await rpc("/fusion_clock/clock_action", {
latitude: lat,
longitude: lng,
accuracy: acc,
source: "backend_fab",
});
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;
}
_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);
}
}
registry.category("main_components").add("FusionClockFAB", {
Component: FusionClockFAB,
});