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