Initial commit
This commit is contained in:
1259
fusion_clock/static/src/css/portal_clock.css
Normal file
1259
fusion_clock/static/src/css/portal_clock.css
Normal file
File diff suppressed because it is too large
Load Diff
300
fusion_clock/static/src/js/fclk_location_map.js
Normal file
300
fusion_clock/static/src/js/fclk_location_map.js
Normal 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);
|
||||
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);
|
||||
330
fusion_clock/static/src/js/fusion_clock_portal_fab.js
Normal file
330
fusion_clock/static/src/js/fusion_clock_portal_fab.js
Normal 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);
|
||||
183
fusion_clock/static/src/js/fusion_clock_systray.js
Normal file
183
fusion_clock/static/src/js/fusion_clock_systray.js
Normal 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,
|
||||
});
|
||||
378
fusion_clock/static/src/scss/fusion_clock.scss
Normal file
378
fusion_clock/static/src/scss/fusion_clock.scss
Normal file
@@ -0,0 +1,378 @@
|
||||
/* ============================================================
|
||||
Fusion Clock - Floating Action Button (FAB)
|
||||
Bottom-left corner clock widget with ripple animation
|
||||
Theme-aware: adapts to Odoo light / dark mode
|
||||
============================================================ */
|
||||
|
||||
// ---- Light-mode tokens (default) ----
|
||||
:root {
|
||||
--fclk-fab-panel-bg: #ffffff;
|
||||
--fclk-fab-panel-border: #e5e7eb;
|
||||
--fclk-fab-panel-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
--fclk-fab-text: #1f2937;
|
||||
--fclk-fab-muted: #6b7280;
|
||||
--fclk-fab-divider: #e5e7eb;
|
||||
--fclk-fab-location-bg: rgba(16, 185, 129, 0.08);
|
||||
--fclk-fab-error-bg: rgba(239, 68, 68, 0.06);
|
||||
--fclk-fab-arrow-bg: #ffffff;
|
||||
}
|
||||
|
||||
// ---- Dark-mode tokens ----
|
||||
html.o_dark {
|
||||
--fclk-fab-panel-bg: #1e2028;
|
||||
--fclk-fab-panel-border: #3a3d48;
|
||||
--fclk-fab-panel-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
--fclk-fab-text: #f1f1f4;
|
||||
--fclk-fab-muted: #9ca3af;
|
||||
--fclk-fab-divider: #3a3d48;
|
||||
--fclk-fab-location-bg: rgba(16, 185, 129, 0.1);
|
||||
--fclk-fab-error-bg: rgba(239, 68, 68, 0.1);
|
||||
--fclk-fab-arrow-bg: #1e2028;
|
||||
}
|
||||
|
||||
// Static color palette
|
||||
$fclk-teal: #0d9488;
|
||||
$fclk-blue: #3b82f6;
|
||||
$fclk-green: #10B981;
|
||||
$fclk-red: #ef4444;
|
||||
|
||||
// Gradient used on the FAB (teal-to-blue like the portal header)
|
||||
$fclk-gradient: linear-gradient(135deg, $fclk-teal 0%, #2563eb 100%);
|
||||
$fclk-gradient-active: linear-gradient(135deg, $fclk-green 0%, $fclk-teal 100%);
|
||||
|
||||
// ===========================================================
|
||||
// Wrapper - anchored bottom-LEFT
|
||||
// ===========================================================
|
||||
.fclk-fab-wrapper {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
z-index: 1050;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
|
||||
> * { pointer-events: auto; }
|
||||
}
|
||||
|
||||
// ===========================================================
|
||||
// FAB Button
|
||||
// ===========================================================
|
||||
.fclk-fab-btn {
|
||||
position: relative;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: $fclk-gradient;
|
||||
color: #fff;
|
||||
font-size: 21px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 20px rgba($fclk-teal, 0.35);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
outline: none;
|
||||
overflow: visible;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 28px rgba($fclk-teal, 0.45);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.93);
|
||||
}
|
||||
|
||||
// Clocked-in: green-teal gradient
|
||||
&.fclk-fab-btn--active {
|
||||
background: $fclk-gradient-active;
|
||||
box-shadow: 0 4px 20px rgba($fclk-green, 0.4);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 6px 28px rgba($fclk-green, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Panel-open: muted
|
||||
&.fclk-fab-btn--open {
|
||||
background: #374151;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.fclk-fab-icon {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
transition: transform 0.3s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&.fclk-fab-btn--open .fclk-fab-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Ripple rings radiating outward from the FAB ----
|
||||
.fclk-fab-ripple-ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba($fclk-green, 0.5);
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
|
||||
&.fclk-fab-ripple-ring--1 {
|
||||
animation: fclk-ripple-out 2.4s ease-out infinite;
|
||||
}
|
||||
&.fclk-fab-ripple-ring--2 {
|
||||
animation: fclk-ripple-out 2.4s ease-out 0.8s infinite;
|
||||
}
|
||||
&.fclk-fab-ripple-ring--3 {
|
||||
animation: fclk-ripple-out 2.4s ease-out 1.6s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fclk-ripple-out {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 0.55;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(2.6);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Mini timer badge ----
|
||||
.fclk-fab-badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #111827;
|
||||
color: $fclk-green;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.5px;
|
||||
border: 1px solid rgba($fclk-green, 0.35);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
animation: fclk-badge-in 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fclk-badge-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(4px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
// ===========================================================
|
||||
// Expanded Panel
|
||||
// ===========================================================
|
||||
.fclk-fab-panel {
|
||||
width: 280px;
|
||||
background: var(--fclk-fab-panel-bg);
|
||||
border: 1px solid var(--fclk-fab-panel-border);
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
box-shadow: var(--fclk-fab-panel-shadow);
|
||||
animation: fclk-panel-slide-up 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@keyframes fclk-panel-slide-up {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
// Arrow pointing down toward the FAB
|
||||
.fclk-fab-panel-arrow {
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 22px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--fclk-fab-arrow-bg);
|
||||
border-right: 1px solid var(--fclk-fab-panel-border);
|
||||
border-bottom: 1px solid var(--fclk-fab-panel-border);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
// ---- Header row ----
|
||||
.fclk-fab-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.fclk-fab-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--fclk-fab-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.fclk-fab-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #9ca3af;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.active {
|
||||
background: $fclk-green;
|
||||
box-shadow: 0 0 6px rgba($fclk-green, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-fab-open-link {
|
||||
color: var(--fclk-fab-muted);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover { color: $fclk-blue; }
|
||||
}
|
||||
|
||||
// ---- Location chip ----
|
||||
.fclk-fab-location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: $fclk-green;
|
||||
background: var(--fclk-fab-location-bg);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.fa { font-size: 12px; }
|
||||
}
|
||||
|
||||
// ---- Timer ----
|
||||
.fclk-fab-timer {
|
||||
text-align: center;
|
||||
color: var(--fclk-fab-text);
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
letter-spacing: 2px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-bottom: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
// ---- Stats row ----
|
||||
.fclk-fab-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.fclk-fab-stat {
|
||||
text-align: center;
|
||||
|
||||
.fclk-fab-stat-val {
|
||||
display: block;
|
||||
color: var(--fclk-fab-text);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fclk-fab-stat-lbl {
|
||||
display: block;
|
||||
color: var(--fclk-fab-muted);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-fab-stat-divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: var(--fclk-fab-divider);
|
||||
}
|
||||
|
||||
// ---- Action button ----
|
||||
.fclk-fab-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 11px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
.fa { font-size: 15px; }
|
||||
|
||||
&.fclk-fab-action--in {
|
||||
background: $fclk-gradient;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 16px rgba($fclk-teal, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
&.fclk-fab-action--out {
|
||||
background: $fclk-red;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 16px rgba($fclk-red, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Error ----
|
||||
.fclk-fab-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
color: $fclk-red;
|
||||
font-size: 11px;
|
||||
background: var(--fclk-fab-error-bg);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
margin-top: 10px;
|
||||
animation: fclk-shake 0.35s ease;
|
||||
line-height: 1.4;
|
||||
|
||||
.fa { font-size: 12px; margin-top: 1px; flex-shrink: 0; }
|
||||
}
|
||||
|
||||
@keyframes fclk-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-4px); }
|
||||
75% { transform: translateX(4px); }
|
||||
}
|
||||
47
fusion_clock/static/src/xml/location_map.xml
Normal file
47
fusion_clock/static/src/xml/location_map.xml
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<!-- Places Autocomplete Address Field (inherits CharField, turns off browser autocomplete) -->
|
||||
<t t-name="fusion_clock.PlacesAddress" t-inherit="web.CharField" t-inherit-mode="primary">
|
||||
<xpath expr="//input" position="attributes">
|
||||
<attribute name="autocomplete">off</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<!-- Interactive Map Widget -->
|
||||
<t t-name="fusion_clock.LocationMap">
|
||||
<div class="fclk-map-widget">
|
||||
<!-- Search box (edit mode only) -->
|
||||
<div t-if="state.loaded and !props.readonly" class="mb-2">
|
||||
<input t-ref="searchInput" type="text"
|
||||
class="form-control"
|
||||
placeholder="Search for a place or address..."/>
|
||||
</div>
|
||||
|
||||
<!-- Map container -->
|
||||
<div t-ref="mapContainer"
|
||||
style="width:100%; height:400px; border-radius:8px; border:1px solid var(--o-border-color, #dee2e6);"/>
|
||||
|
||||
<!-- Coordinate display -->
|
||||
<div t-if="state.loaded and !props.readonly"
|
||||
class="mt-2 text-muted small">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
Drag the pin or use the search box to adjust. Changes save with the form.
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div t-if="!state.loaded and !state.error"
|
||||
class="text-center p-4 text-muted">
|
||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||
<div class="mt-2">Loading map...</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div t-if="state.error" class="alert alert-warning mt-2 mb-0">
|
||||
<i class="fa fa-exclamation-triangle me-1"/>
|
||||
<t t-esc="state.error"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
88
fusion_clock/static/src/xml/systray_clock.xml
Normal file
88
fusion_clock/static/src/xml/systray_clock.xml
Normal file
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_clock.ClockFAB">
|
||||
<div t-if="state.isDisplayed" class="fclk-fab-wrapper">
|
||||
|
||||
<!-- Expanded Panel (above the button) -->
|
||||
<div t-if="state.expanded" class="fclk-fab-panel">
|
||||
<!-- Header -->
|
||||
<div class="fclk-fab-panel-header">
|
||||
<div class="fclk-fab-panel-title">
|
||||
<span t-attf-class="fclk-fab-status-dot {{ state.isCheckedIn ? 'active' : '' }}"/>
|
||||
<span t-if="state.isCheckedIn">Clocked In</span>
|
||||
<span t-else="">Ready</span>
|
||||
</div>
|
||||
<a href="/my/clock" class="fclk-fab-open-link" target="_blank" title="Open Full Clock">
|
||||
<i class="fa fa-external-link"/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div t-if="state.isCheckedIn and state.locationName" class="fclk-fab-location">
|
||||
<i class="fa fa-map-marker"/>
|
||||
<span t-esc="state.locationName"/>
|
||||
</div>
|
||||
|
||||
<!-- Timer -->
|
||||
<div class="fclk-fab-timer" t-esc="state.timerDisplay"/>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="fclk-fab-stats">
|
||||
<div class="fclk-fab-stat">
|
||||
<span class="fclk-fab-stat-val"><t t-esc="state.todayHours"/>h</span>
|
||||
<span class="fclk-fab-stat-lbl">Today</span>
|
||||
</div>
|
||||
<div class="fclk-fab-stat-divider"/>
|
||||
<div class="fclk-fab-stat">
|
||||
<span class="fclk-fab-stat-val"><t t-esc="state.weekHours"/>h</span>
|
||||
<span class="fclk-fab-stat-lbl">Week</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clock Action Button -->
|
||||
<button t-attf-class="fclk-fab-action {{ state.isCheckedIn ? 'fclk-fab-action--out' : 'fclk-fab-action--in' }}"
|
||||
t-on-click="onClockAction"
|
||||
t-att-disabled="state.loading">
|
||||
<t t-if="state.loading">
|
||||
<i class="fa fa-circle-o-notch fa-spin"/> Working...
|
||||
</t>
|
||||
<t t-elif="state.isCheckedIn">
|
||||
<i class="fa fa-stop-circle-o"/> Clock Out
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i class="fa fa-play-circle-o"/> Clock In
|
||||
</t>
|
||||
</button>
|
||||
|
||||
<!-- Error -->
|
||||
<div t-if="state.error" class="fclk-fab-error">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
<t t-esc="state.error"/>
|
||||
</div>
|
||||
|
||||
<!-- Arrow pointing to button -->
|
||||
<div class="fclk-fab-panel-arrow"/>
|
||||
</div>
|
||||
|
||||
<!-- Floating Action Button -->
|
||||
<button t-attf-class="fclk-fab-btn {{ state.isCheckedIn ? 'fclk-fab-btn--active' : '' }} {{ state.expanded ? 'fclk-fab-btn--open' : '' }}"
|
||||
t-on-click="togglePanel">
|
||||
<!-- Ripple rings (always animate) -->
|
||||
<span t-if="state.isCheckedIn" class="fclk-fab-ripple-ring fclk-fab-ripple-ring--1"/>
|
||||
<span t-if="state.isCheckedIn" class="fclk-fab-ripple-ring fclk-fab-ripple-ring--2"/>
|
||||
<span t-if="state.isCheckedIn" class="fclk-fab-ripple-ring fclk-fab-ripple-ring--3"/>
|
||||
<!-- Icon -->
|
||||
<span class="fclk-fab-icon">
|
||||
<i t-if="!state.expanded" class="fa fa-clock-o"/>
|
||||
<i t-else="" class="fa fa-times"/>
|
||||
</span>
|
||||
<!-- Mini timer badge -->
|
||||
<span t-if="state.isCheckedIn and !state.expanded" class="fclk-fab-badge">
|
||||
<t t-esc="state.timerDisplay"/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Reference in New Issue
Block a user