changes
This commit is contained in:
67
fusion_clock/static/src/js/fusion_clock_dashboard.js
Normal file
67
fusion_clock/static/src/js/fusion_clock_dashboard.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState, onWillStart } 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 FusionClockDashboard extends Component {
|
||||
static template = "fusion_clock.Dashboard";
|
||||
static props = { "*": true };
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
clocked_in: [],
|
||||
total_employees: 0,
|
||||
present_count: 0,
|
||||
absent_count: 0,
|
||||
late_count: 0,
|
||||
pending_reasons: 0,
|
||||
pending_corrections: 0,
|
||||
error: "",
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
await this._fetchData();
|
||||
});
|
||||
}
|
||||
|
||||
async _fetchData() {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const data = await rpc("/fusion_clock/dashboard_data", {});
|
||||
if (data.error) {
|
||||
this.state.error = data.error;
|
||||
} else {
|
||||
Object.assign(this.state, data);
|
||||
}
|
||||
} catch (e) {
|
||||
this.state.error = "Failed to load dashboard data.";
|
||||
}
|
||||
this.state.loading = false;
|
||||
}
|
||||
|
||||
async onRefresh() {
|
||||
await this._fetchData();
|
||||
}
|
||||
|
||||
onViewAttendances() {
|
||||
this.action.doAction("hr_attendance.hr_attendance_action");
|
||||
}
|
||||
|
||||
onViewCorrections() {
|
||||
this.action.doAction("fusion_clock.action_fusion_clock_correction");
|
||||
}
|
||||
|
||||
onViewActivityLogs() {
|
||||
this.action.doAction("fusion_clock.action_fusion_clock_activity_log");
|
||||
}
|
||||
|
||||
onViewPenalties() {
|
||||
this.action.doAction("fusion_clock.action_fusion_clock_penalty");
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fusion_clock.Dashboard", FusionClockDashboard);
|
||||
228
fusion_clock/static/src/js/fusion_clock_kiosk.js
Normal file
228
fusion_clock/static/src/js/fusion_clock_kiosk.js
Normal file
@@ -0,0 +1,228 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Interaction } from "@web/public/interaction";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class FusionClockKiosk extends Interaction {
|
||||
static selector = "#fclk-kiosk";
|
||||
|
||||
setup() {
|
||||
this.selectedEmployeeId = 0;
|
||||
this.resetTimer = null;
|
||||
this.searchTimeout = null;
|
||||
|
||||
const pinAttr = this.el.dataset.pinRequired;
|
||||
this.pinRequired = pinAttr === "true" || pinAttr === "True";
|
||||
|
||||
this._startClock();
|
||||
this._bindEvents();
|
||||
}
|
||||
|
||||
_startClock() {
|
||||
const el = document.getElementById("fclk-kiosk-time");
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
el.textContent = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
};
|
||||
update();
|
||||
setInterval(update, 1000);
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
const queryInput = document.getElementById("fclk-kiosk-query");
|
||||
if (queryInput) {
|
||||
queryInput.addEventListener("input", (e) => this._onSearch(e.target.value));
|
||||
}
|
||||
|
||||
const backBtn = document.getElementById("fclk-kiosk-back-btn");
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener("click", () => this._resetKiosk());
|
||||
}
|
||||
|
||||
const clockBtn = document.getElementById("fclk-kiosk-clock-btn");
|
||||
if (clockBtn) {
|
||||
clockBtn.addEventListener("click", () => this._onClock());
|
||||
}
|
||||
}
|
||||
|
||||
_resetKiosk() {
|
||||
const search = document.getElementById("fclk-kiosk-search");
|
||||
const pin = document.getElementById("fclk-kiosk-pin");
|
||||
const result = document.getElementById("fclk-kiosk-result");
|
||||
const error = document.getElementById("fclk-kiosk-error");
|
||||
const query = document.getElementById("fclk-kiosk-query");
|
||||
const results = document.getElementById("fclk-kiosk-results");
|
||||
const pinInput = document.getElementById("fclk-kiosk-pin-input");
|
||||
|
||||
if (search) search.style.display = "";
|
||||
if (pin) pin.style.display = "none";
|
||||
if (result) result.style.display = "none";
|
||||
if (error) error.style.display = "none";
|
||||
if (query) query.value = "";
|
||||
if (results) results.innerHTML = "";
|
||||
if (pinInput) pinInput.value = "";
|
||||
|
||||
this.selectedEmployeeId = 0;
|
||||
if (this.resetTimer) clearTimeout(this.resetTimer);
|
||||
}
|
||||
|
||||
_showError(msg) {
|
||||
const el = document.getElementById("fclk-kiosk-error");
|
||||
if (el) {
|
||||
el.textContent = msg;
|
||||
el.style.display = "";
|
||||
}
|
||||
}
|
||||
|
||||
_onSearch(value) {
|
||||
if (this.searchTimeout) clearTimeout(this.searchTimeout);
|
||||
const q = value.trim();
|
||||
if (q.length < 2) {
|
||||
const container = document.getElementById("fclk-kiosk-results");
|
||||
if (container) container.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
this.searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const resp = await fetch("/fusion_clock/kiosk/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", method: "call", params: { query: q } }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
const employees = (data.result || {}).employees || [];
|
||||
const container = document.getElementById("fclk-kiosk-results");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
for (const emp of employees) {
|
||||
const item = document.createElement("a");
|
||||
item.href = "#";
|
||||
item.className = "list-group-item list-group-item-action d-flex justify-content-between";
|
||||
const statusBadge = emp.is_checked_in ? "bg-success" : "bg-secondary";
|
||||
const statusText = emp.is_checked_in ? "In" : "Out";
|
||||
item.innerHTML =
|
||||
`<span>${emp.name} <small class="text-muted">${emp.department}</small></span>` +
|
||||
`<span class="badge ${statusBadge}">${statusText}</span>`;
|
||||
item.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
this._selectEmployee(emp);
|
||||
});
|
||||
container.appendChild(item);
|
||||
}
|
||||
} catch {
|
||||
this._showError("Search failed.");
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
_selectEmployee(emp) {
|
||||
this.selectedEmployeeId = emp.id;
|
||||
const nameEl = document.getElementById("fclk-kiosk-emp-name");
|
||||
if (nameEl) nameEl.textContent = emp.name;
|
||||
|
||||
const searchEl = document.getElementById("fclk-kiosk-search");
|
||||
const pinEl = document.getElementById("fclk-kiosk-pin");
|
||||
const errorEl = document.getElementById("fclk-kiosk-error");
|
||||
if (searchEl) searchEl.style.display = "none";
|
||||
if (pinEl) pinEl.style.display = "";
|
||||
if (errorEl) errorEl.style.display = "none";
|
||||
|
||||
const clockBtn = document.getElementById("fclk-kiosk-clock-btn");
|
||||
if (clockBtn) {
|
||||
clockBtn.textContent = emp.is_checked_in ? "Clock Out" : "Clock In";
|
||||
clockBtn.className = "btn btn-lg " + (emp.is_checked_in ? "btn-danger" : "btn-success");
|
||||
}
|
||||
}
|
||||
|
||||
async _onClock() {
|
||||
if (!this.selectedEmployeeId) return;
|
||||
|
||||
const btn = document.getElementById("fclk-kiosk-clock-btn");
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
const pinInput = document.getElementById("fclk-kiosk-pin-input");
|
||||
const pin = pinInput ? pinInput.value : "";
|
||||
|
||||
if (this.pinRequired && pin.length === 0) {
|
||||
this._showError("Please enter your PIN.");
|
||||
if (btn) btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.pinRequired) {
|
||||
const vResp = await fetch("/fusion_clock/kiosk/verify_pin", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "call",
|
||||
params: { employee_id: this.selectedEmployeeId, pin },
|
||||
}),
|
||||
});
|
||||
const vData = await vResp.json();
|
||||
if (vData.result && vData.result.error) {
|
||||
this._showError(vData.result.error);
|
||||
if (btn) btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let lat = 0;
|
||||
let lng = 0;
|
||||
try {
|
||||
const pos = await new Promise((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
timeout: 10000,
|
||||
enableHighAccuracy: true,
|
||||
});
|
||||
});
|
||||
lat = pos.coords.latitude;
|
||||
lng = pos.coords.longitude;
|
||||
} catch {
|
||||
// GPS unavailable on kiosk device
|
||||
}
|
||||
|
||||
const resp = await fetch("/fusion_clock/kiosk/clock", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "call",
|
||||
params: { employee_id: this.selectedEmployeeId, latitude: lat, longitude: lng },
|
||||
}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
const result = data.result || {};
|
||||
|
||||
if (result.error) {
|
||||
this._showError(result.error);
|
||||
if (btn) btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const pinEl = document.getElementById("fclk-kiosk-pin");
|
||||
const resultEl = document.getElementById("fclk-kiosk-result");
|
||||
if (pinEl) pinEl.style.display = "none";
|
||||
if (resultEl) resultEl.style.display = "";
|
||||
|
||||
const msgEl = document.getElementById("fclk-kiosk-result-msg");
|
||||
if (msgEl) {
|
||||
const icon = result.action === "clock_in" ? "fa-check-circle text-success" : "fa-hand-paper-o text-warning";
|
||||
let html = `<div style="font-size:3rem"><i class="fa ${icon}"></i></div>`;
|
||||
html += `<div class="mt-2">${result.message || "Done"}</div>`;
|
||||
if (result.net_hours !== undefined) {
|
||||
html += `<div class="text-muted mt-1">Net hours: ${result.net_hours}h</div>`;
|
||||
}
|
||||
msgEl.innerHTML = html;
|
||||
}
|
||||
|
||||
this.resetTimer = setTimeout(() => this._resetKiosk(), 10000);
|
||||
} catch {
|
||||
this._showError("Operation failed.");
|
||||
}
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("public.interactions").add("fusion_clock.kiosk", FusionClockKiosk);
|
||||
247
fusion_clock/static/src/js/fusion_clock_location_map.js
Normal file
247
fusion_clock/static/src/js/fusion_clock_location_map.js
Normal file
@@ -0,0 +1,247 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
export class FusionClockLocationMap extends Component {
|
||||
static template = "fusion_clock.LocationMap";
|
||||
static props = { ...standardFieldProps };
|
||||
|
||||
setup() {
|
||||
this.mapRef = useRef("mapContainer");
|
||||
this.map = null;
|
||||
this.marker = null;
|
||||
this.circle = null;
|
||||
this._suppress = false;
|
||||
this._interval = null;
|
||||
this._AdvancedMarkerElement = null;
|
||||
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
error: "",
|
||||
mapVisible: false,
|
||||
});
|
||||
|
||||
onMounted(() => this._init());
|
||||
onWillUnmount(() => this._cleanup());
|
||||
}
|
||||
|
||||
get lat() { return this.props.record.data.latitude || 0; }
|
||||
get lng() { return this.props.record.data.longitude || 0; }
|
||||
get radius() { return this.props.record.data.radius || 100; }
|
||||
get color() { return this.props.record.data.color || "#10B981"; }
|
||||
get hasCoords() { return this.lat !== 0 || this.lng !== 0; }
|
||||
|
||||
async _init() {
|
||||
const apiKey = await this._getApiKey();
|
||||
if (!apiKey) {
|
||||
this.state.loading = false;
|
||||
this.state.error = "Google Maps API key not configured. Set it in Fusion Clock Settings.";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this._loadScript(apiKey);
|
||||
} catch {
|
||||
this.state.loading = false;
|
||||
this.state.error = "Failed to load Google Maps API.";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { AdvancedMarkerElement } = await google.maps.importLibrary("marker");
|
||||
this._AdvancedMarkerElement = AdvancedMarkerElement;
|
||||
} catch {
|
||||
this.state.loading = false;
|
||||
this.state.error = "Failed to load marker library.";
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.loading = false;
|
||||
|
||||
if (!this.hasCoords) {
|
||||
this._startWatcher();
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.mapVisible = true;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
this._buildMap();
|
||||
}
|
||||
|
||||
_buildMap() {
|
||||
const el = this.mapRef.el;
|
||||
if (!el || !window.google || !this._AdvancedMarkerElement) return;
|
||||
|
||||
const center = { lat: this.lat, lng: this.lng };
|
||||
|
||||
this.map = new google.maps.Map(el, {
|
||||
center,
|
||||
zoom: 17,
|
||||
mapId: "DEMO_MAP_ID",
|
||||
mapTypeControl: true,
|
||||
mapTypeControlOptions: {
|
||||
style: google.maps.MapTypeControlStyle.DROPDOWN_MENU,
|
||||
position: google.maps.ControlPosition.TOP_RIGHT,
|
||||
mapTypeIds: ["roadmap", "satellite", "hybrid"],
|
||||
},
|
||||
streetViewControl: false,
|
||||
fullscreenControl: true,
|
||||
zoomControl: true,
|
||||
gestureHandling: "greedy",
|
||||
});
|
||||
|
||||
this._placeMarker(center);
|
||||
this._drawCircle(center);
|
||||
|
||||
if (!this.props.readonly) {
|
||||
this.map.addListener("click", (e) => {
|
||||
const pos = { lat: e.latLng.lat(), lng: e.latLng.lng() };
|
||||
this._placeMarker(pos);
|
||||
this._drawCircle(pos);
|
||||
this._suppress = true;
|
||||
this._saveCoords(pos.lat, pos.lng);
|
||||
});
|
||||
}
|
||||
|
||||
this._startWatcher();
|
||||
}
|
||||
|
||||
_placeMarker(pos) {
|
||||
if (this.marker) {
|
||||
this.marker.position = pos;
|
||||
return;
|
||||
}
|
||||
|
||||
this.marker = new this._AdvancedMarkerElement({
|
||||
map: this.map,
|
||||
position: pos,
|
||||
gmpDraggable: !this.props.readonly,
|
||||
title: "Drag to fine-tune location",
|
||||
});
|
||||
|
||||
if (!this.props.readonly) {
|
||||
this.marker.addListener("dragend", () => {
|
||||
const p = this.marker.position;
|
||||
const newPos = { lat: p.lat, lng: p.lng };
|
||||
this._drawCircle(newPos);
|
||||
this._suppress = true;
|
||||
this._saveCoords(newPos.lat, newPos.lng);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_drawCircle(center) {
|
||||
if (this.circle) {
|
||||
this.circle.setCenter(center);
|
||||
this.circle.setRadius(this.radius);
|
||||
this.circle.setOptions({ fillColor: this.color, strokeColor: this.color });
|
||||
} else {
|
||||
this.circle = new google.maps.Circle({
|
||||
map: this.map,
|
||||
center,
|
||||
radius: this.radius,
|
||||
fillColor: this.color,
|
||||
fillOpacity: 0.15,
|
||||
strokeColor: this.color,
|
||||
strokeOpacity: 0.6,
|
||||
strokeWeight: 2,
|
||||
clickable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async _saveCoords(lat, lng) {
|
||||
if (this.props.readonly) return;
|
||||
await this.props.record.update({
|
||||
latitude: Math.round(lat * 10000000) / 10000000,
|
||||
longitude: Math.round(lng * 10000000) / 10000000,
|
||||
});
|
||||
}
|
||||
|
||||
_startWatcher() {
|
||||
if (this._interval) return;
|
||||
this._lastLat = this.lat;
|
||||
this._lastLng = this.lng;
|
||||
this._lastRadius = this.radius;
|
||||
|
||||
this._interval = setInterval(() => {
|
||||
const lat = this.lat;
|
||||
const lng = this.lng;
|
||||
const r = this.radius;
|
||||
|
||||
const moved = Math.abs(this._lastLat - lat) > 0.0000001
|
||||
|| Math.abs(this._lastLng - lng) > 0.0000001;
|
||||
const resized = Math.abs(this._lastRadius - r) > 0.5;
|
||||
|
||||
if (moved && this.map) {
|
||||
this._lastLat = lat;
|
||||
this._lastLng = lng;
|
||||
if (this._suppress) { this._suppress = false; return; }
|
||||
const pos = { lat, lng };
|
||||
this._placeMarker(pos);
|
||||
this._drawCircle(pos);
|
||||
this.map.panTo(pos);
|
||||
}
|
||||
|
||||
if (resized && this.circle) {
|
||||
this._lastRadius = r;
|
||||
this.circle.setRadius(r);
|
||||
}
|
||||
|
||||
if (!this.map && this.hasCoords && !this.state.error && this._AdvancedMarkerElement) {
|
||||
this.state.mapVisible = true;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this._buildMap();
|
||||
});
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async _getApiKey() {
|
||||
try {
|
||||
return await rpc("/web/dataset/call_kw", {
|
||||
model: "ir.config_parameter",
|
||||
method: "get_param",
|
||||
args: ["fusion_clock.google_maps_api_key", ""],
|
||||
kwargs: {},
|
||||
}) || "";
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
async _loadScript(apiKey) {
|
||||
if (window.google && window.google.maps) return;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (document.querySelector('script[src*="maps.googleapis.com"]')) {
|
||||
const t = setInterval(() => {
|
||||
if (window.google && window.google.maps) { clearInterval(t); resolve(); }
|
||||
}, 100);
|
||||
setTimeout(() => { clearInterval(t); resolve(); }, 5000);
|
||||
return;
|
||||
}
|
||||
const s = document.createElement("script");
|
||||
s.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places&callback=__fclkMapCb`;
|
||||
s.async = true;
|
||||
s.defer = true;
|
||||
window.__fclkMapCb = () => { delete window.__fclkMapCb; resolve(); };
|
||||
s.onerror = () => reject(new Error("script load failed"));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
if (this._interval) clearInterval(this._interval);
|
||||
if (this.marker) { this.marker.map = null; this.marker = null; }
|
||||
if (this.circle) { this.circle.setMap(null); this.circle = null; }
|
||||
this.map = null;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("fields").add("fclk_location_map", {
|
||||
component: FusionClockLocationMap,
|
||||
supportedTypes: ["char"],
|
||||
});
|
||||
150
fusion_clock/static/src/js/fusion_clock_location_places.js
Normal file
150
fusion_clock/static/src/js/fusion_clock_location_places.js
Normal file
@@ -0,0 +1,150 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
/**
|
||||
* Google Places Autocomplete widget for the address field.
|
||||
* Automatically geocodes the selected place and updates lat/lng on the record.
|
||||
*/
|
||||
export class FusionClockPlacesAutocomplete extends Component {
|
||||
static template = "fusion_clock.PlacesAutocomplete";
|
||||
static props = { ...standardFieldProps };
|
||||
|
||||
setup() {
|
||||
this.inputRef = useRef("addressInput");
|
||||
this.autocomplete = null;
|
||||
this._apiReady = false;
|
||||
|
||||
this.state = useState({
|
||||
value: this.props.record.data[this.props.name] || "",
|
||||
});
|
||||
|
||||
onMounted(() => this._init());
|
||||
onWillUnmount(() => this._cleanup());
|
||||
}
|
||||
|
||||
get isReadonly() {
|
||||
return this.props.readonly;
|
||||
}
|
||||
|
||||
async _getApiKey() {
|
||||
try {
|
||||
return await rpc("/web/dataset/call_kw", {
|
||||
model: "ir.config_parameter",
|
||||
method: "get_param",
|
||||
args: ["fusion_clock.google_maps_api_key", ""],
|
||||
kwargs: {},
|
||||
}) || "";
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async _waitForGoogleMaps() {
|
||||
if (window.google && window.google.maps && window.google.maps.places) {
|
||||
return true;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let attempts = 0;
|
||||
const check = setInterval(() => {
|
||||
attempts++;
|
||||
if (window.google && window.google.maps && window.google.maps.places) {
|
||||
clearInterval(check);
|
||||
resolve(true);
|
||||
}
|
||||
if (attempts > 50) {
|
||||
clearInterval(check);
|
||||
resolve(false);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
async _loadGoogleMaps(apiKey) {
|
||||
if (window.google && window.google.maps) return;
|
||||
|
||||
if (document.querySelector('script[src*="maps.googleapis.com"]')) {
|
||||
await this._waitForGoogleMaps();
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places&callback=__fclkPlacesInit`;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
window.__fclkPlacesInit = () => {
|
||||
delete window.__fclkPlacesInit;
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => reject(new Error("Failed to load Google Maps"));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
async _init() {
|
||||
if (this.isReadonly) return;
|
||||
|
||||
const apiKey = await this._getApiKey();
|
||||
if (!apiKey) return;
|
||||
|
||||
try {
|
||||
await this._loadGoogleMaps(apiKey);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this._waitForGoogleMaps();
|
||||
|
||||
if (!this.inputRef.el || !window.google || !window.google.maps.places) return;
|
||||
|
||||
this.autocomplete = new google.maps.places.Autocomplete(this.inputRef.el, {
|
||||
types: ["geocode", "establishment"],
|
||||
fields: ["formatted_address", "geometry", "name"],
|
||||
});
|
||||
|
||||
this.autocomplete.addListener("place_changed", () => {
|
||||
const place = this.autocomplete.getPlace();
|
||||
if (!place || !place.geometry) return;
|
||||
|
||||
const lat = place.geometry.location.lat();
|
||||
const lng = place.geometry.location.lng();
|
||||
const address = place.formatted_address || place.name || "";
|
||||
|
||||
this.state.value = address;
|
||||
this.props.record.update({
|
||||
[this.props.name]: address,
|
||||
latitude: Math.round(lat * 10000000) / 10000000,
|
||||
longitude: Math.round(lng * 10000000) / 10000000,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onInput(ev) {
|
||||
this.state.value = ev.target.value;
|
||||
}
|
||||
|
||||
onChange(ev) {
|
||||
this.props.record.update({ [this.props.name]: ev.target.value });
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
if (this.autocomplete) {
|
||||
google.maps.event.clearInstanceListeners(this.autocomplete);
|
||||
this.autocomplete = null;
|
||||
}
|
||||
|
||||
const containers = document.querySelectorAll(".pac-container");
|
||||
containers.forEach((c) => c.remove());
|
||||
}
|
||||
}
|
||||
|
||||
FusionClockPlacesAutocomplete.template = "fusion_clock.PlacesAutocomplete";
|
||||
|
||||
registry.category("fields").add("fclk_places_autocomplete", {
|
||||
component: FusionClockPlacesAutocomplete,
|
||||
supportedTypes: ["char"],
|
||||
});
|
||||
@@ -79,6 +79,37 @@ export class FusionClockPortal extends Interaction {
|
||||
});
|
||||
}
|
||||
|
||||
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", () => {
|
||||
this.selectedLocationId = parseInt(item.dataset.id);
|
||||
@@ -100,9 +131,54 @@ export class FusionClockPortal extends Interaction {
|
||||
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;
|
||||
|
||||
// Ripple effect
|
||||
const ripple = btn.querySelector(".fclk-btn-ripple");
|
||||
if (ripple) {
|
||||
ripple.classList.remove("fclk-ripple-active");
|
||||
@@ -150,6 +226,11 @@ export class FusionClockPortal extends Interaction {
|
||||
this._hideGPSOverlay();
|
||||
if (btn) btn.disabled = false;
|
||||
|
||||
if (result.requires_reason) {
|
||||
this._showReasonModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
this._showToast(result.error, "error");
|
||||
this._shakeButton();
|
||||
@@ -413,6 +494,75 @@ export class FusionClockPortal extends Interaction {
|
||||
} 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 depTime = timeEl ? timeEl.value.trim() : "";
|
||||
|
||||
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 dateEl = document.getElementById("fclk-leave-date");
|
||||
const reasonEl = document.getElementById("fclk-leave-reason");
|
||||
const leaveDate = dateEl ? dateEl.value : "";
|
||||
const reason = reasonEl ? reasonEl.value.trim() : "";
|
||||
|
||||
if (!leaveDate || !reason) {
|
||||
this._showToast("Please provide both a date and reason.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await rpc("/fusion_clock/request_leave", {
|
||||
leave_date: leaveDate,
|
||||
reason: reason,
|
||||
});
|
||||
if (result.success) {
|
||||
const modal = document.getElementById("fclk-leave-modal");
|
||||
if (modal) modal.style.display = "none";
|
||||
this._showToast(result.message, "success");
|
||||
if (dateEl) dateEl.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
|
||||
// =========================================================================
|
||||
|
||||
@@ -223,6 +223,163 @@ export class FusionClockPortalFAB extends Interaction {
|
||||
// =========================================================================
|
||||
|
||||
async _onClockAction() {
|
||||
if (this.isCheckedIn) {
|
||||
this._showClockOutConfirm();
|
||||
return;
|
||||
}
|
||||
await this._executeClockAction();
|
||||
}
|
||||
|
||||
_showClockOutConfirm() {
|
||||
let modal = document.getElementById("fclk-pfab-clockout-modal");
|
||||
if (!modal) {
|
||||
modal = document.createElement("div");
|
||||
modal.id = "fclk-pfab-clockout-modal";
|
||||
modal.className = "fclk-wizard-overlay";
|
||||
modal.innerHTML = `
|
||||
<div class="fclk-wizard-backdrop" data-pfab-dismiss="fclk-pfab-clockout-modal"></div>
|
||||
<div class="fclk-wizard-dialog fclk-wizard-dialog--compact">
|
||||
<div class="fclk-wizard-header fclk-wizard-header--danger">
|
||||
<div class="fclk-wizard-header-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
|
||||
</div>
|
||||
<h3 class="fclk-wizard-title">Clock Out?</h3>
|
||||
<p class="fclk-wizard-subtitle">Are you sure you want to end your current shift?</p>
|
||||
</div>
|
||||
<div class="fclk-wizard-body">
|
||||
<div class="fclk-clockout-summary">
|
||||
<div class="fclk-clockout-summary-row">
|
||||
<span class="fclk-clockout-summary-label">Clocked in at</span>
|
||||
<span class="fclk-clockout-summary-value" id="fclk-pfab-confirm-time">--</span>
|
||||
</div>
|
||||
<div class="fclk-clockout-summary-row">
|
||||
<span class="fclk-clockout-summary-label">Duration</span>
|
||||
<span class="fclk-clockout-summary-value" id="fclk-pfab-confirm-dur">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fclk-wizard-footer">
|
||||
<button class="fclk-wizard-btn fclk-wizard-btn--secondary" data-pfab-dismiss="fclk-pfab-clockout-modal">Cancel</button>
|
||||
<button class="fclk-wizard-btn fclk-wizard-btn--danger" id="fclk-pfab-confirm-clockout-btn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
|
||||
Confirm Clock Out
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
modal.querySelectorAll("[data-pfab-dismiss]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => { modal.style.display = "none"; });
|
||||
});
|
||||
document.getElementById("fclk-pfab-confirm-clockout-btn").addEventListener("click", () => {
|
||||
modal.style.display = "none";
|
||||
this._executeClockAction();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.checkInTime) {
|
||||
const h = this.checkInTime.getHours();
|
||||
const m = this.checkInTime.getMinutes();
|
||||
const ampm = h >= 12 ? "PM" : "AM";
|
||||
const hour12 = h % 12 || 12;
|
||||
const timeEl = document.getElementById("fclk-pfab-confirm-time");
|
||||
if (timeEl) timeEl.textContent = hour12 + ":" + (m < 10 ? "0" : "") + m + " " + ampm;
|
||||
|
||||
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);
|
||||
const durEl = document.getElementById("fclk-pfab-confirm-dur");
|
||||
if (durEl) durEl.textContent = dh + "h " + dm + "m";
|
||||
}
|
||||
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
_showReasonDialog() {
|
||||
let modal = document.getElementById("fclk-pfab-reason-modal");
|
||||
if (!modal) {
|
||||
modal = document.createElement("div");
|
||||
modal.id = "fclk-pfab-reason-modal";
|
||||
modal.className = "fclk-wizard-overlay";
|
||||
modal.innerHTML = `
|
||||
<div class="fclk-wizard-backdrop" data-pfab-dismiss="fclk-pfab-reason-modal"></div>
|
||||
<div class="fclk-wizard-dialog">
|
||||
<div class="fclk-wizard-header fclk-wizard-header--warning">
|
||||
<div class="fclk-wizard-header-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="fclk-wizard-title">Missed Clock-Out</h3>
|
||||
<p class="fclk-wizard-subtitle">You didn't clock out on your last shift. Please provide details before continuing.</p>
|
||||
</div>
|
||||
<div class="fclk-wizard-body">
|
||||
<div class="fclk-wizard-field">
|
||||
<label class="fclk-wizard-label">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
Reason <span class="fclk-wizard-required">*</span>
|
||||
</label>
|
||||
<textarea id="fclk-pfab-reason-text" class="fclk-wizard-input fclk-wizard-textarea" rows="3"
|
||||
placeholder="Please explain why you didn't clock out..."></textarea>
|
||||
</div>
|
||||
<div class="fclk-wizard-field">
|
||||
<label class="fclk-wizard-label">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
Departure Time
|
||||
</label>
|
||||
<input type="datetime-local" id="fclk-pfab-reason-time" class="fclk-wizard-input"/>
|
||||
<span class="fclk-wizard-hint">When did you actually leave? (optional)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fclk-wizard-footer">
|
||||
<button class="fclk-wizard-btn fclk-wizard-btn--secondary" data-pfab-dismiss="fclk-pfab-reason-modal">Cancel</button>
|
||||
<button class="fclk-wizard-btn fclk-wizard-btn--primary" id="fclk-pfab-reason-submit-btn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
Submit Reason
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
modal.querySelectorAll("[data-pfab-dismiss]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => { modal.style.display = "none"; });
|
||||
});
|
||||
document.getElementById("fclk-pfab-reason-submit-btn").addEventListener("click", async () => {
|
||||
const reasonEl = document.getElementById("fclk-pfab-reason-text");
|
||||
const timeEl = document.getElementById("fclk-pfab-reason-time");
|
||||
const reason = reasonEl ? reasonEl.value.trim() : "";
|
||||
if (!reason) {
|
||||
this._showError("Please provide a reason.");
|
||||
return;
|
||||
}
|
||||
const submitBtn = document.getElementById("fclk-pfab-reason-submit-btn");
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
try {
|
||||
await rpc("/fusion_clock/submit_reason", {
|
||||
reason: reason,
|
||||
departure_time: timeEl ? timeEl.value : "",
|
||||
});
|
||||
modal.style.display = "none";
|
||||
if (reasonEl) reasonEl.value = "";
|
||||
if (timeEl) timeEl.value = "";
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
await this._executeClockAction();
|
||||
} catch (e) {
|
||||
this._showError("Failed to submit reason.");
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const reasonEl = document.getElementById("fclk-pfab-reason-text");
|
||||
const timeEl = document.getElementById("fclk-pfab-reason-time");
|
||||
if (reasonEl) reasonEl.value = "";
|
||||
if (timeEl) timeEl.value = "";
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
async _executeClockAction() {
|
||||
if (this.actionBtn) this.actionBtn.disabled = true;
|
||||
this._clearError();
|
||||
|
||||
@@ -255,6 +412,12 @@ export class FusionClockPortalFAB extends Interaction {
|
||||
source: "portal_fab",
|
||||
});
|
||||
|
||||
if (result.requires_reason) {
|
||||
if (this.actionBtn) this.actionBtn.disabled = false;
|
||||
this._showReasonDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
this._showError(result.error);
|
||||
if (this.actionBtn) this.actionBtn.disabled = false;
|
||||
|
||||
@@ -23,6 +23,11 @@ export class FusionClockFAB extends Component {
|
||||
weekHours: "0.0",
|
||||
loading: false,
|
||||
error: "",
|
||||
showReasonDialog: false,
|
||||
showClockoutConfirm: false,
|
||||
reasonText: "",
|
||||
reasonTime: "",
|
||||
reasonSubmitting: false,
|
||||
});
|
||||
|
||||
this._timerInterval = null;
|
||||
@@ -95,6 +100,23 @@ export class FusionClockFAB extends Component {
|
||||
}
|
||||
|
||||
async onClockAction() {
|
||||
if (this.state.isCheckedIn) {
|
||||
this.state.showClockoutConfirm = true;
|
||||
return;
|
||||
}
|
||||
await this._executeClockAction();
|
||||
}
|
||||
|
||||
async confirmClockOut() {
|
||||
this.state.showClockoutConfirm = false;
|
||||
await this._executeClockAction();
|
||||
}
|
||||
|
||||
cancelClockOut() {
|
||||
this.state.showClockoutConfirm = false;
|
||||
}
|
||||
|
||||
async _executeClockAction() {
|
||||
this.state.loading = true;
|
||||
this.state.error = "";
|
||||
|
||||
@@ -126,6 +148,14 @@ export class FusionClockFAB extends Component {
|
||||
source: "backend_fab",
|
||||
});
|
||||
|
||||
if (result.requires_reason) {
|
||||
this.state.loading = false;
|
||||
this.state.showReasonDialog = true;
|
||||
this.state.reasonText = "";
|
||||
this.state.reasonTime = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
this.state.error = result.error;
|
||||
this.state.loading = false;
|
||||
@@ -153,6 +183,60 @@ export class FusionClockFAB extends Component {
|
||||
this.state.loading = false;
|
||||
}
|
||||
|
||||
onReasonTextInput(ev) {
|
||||
this.state.reasonText = ev.target.value;
|
||||
}
|
||||
|
||||
onReasonTimeInput(ev) {
|
||||
this.state.reasonTime = ev.target.value;
|
||||
}
|
||||
|
||||
cancelReason() {
|
||||
this.state.showReasonDialog = false;
|
||||
this.state.reasonText = "";
|
||||
this.state.reasonTime = "";
|
||||
}
|
||||
|
||||
async submitReason() {
|
||||
if (!this.state.reasonText.trim()) {
|
||||
this.state.error = "Please provide a reason.";
|
||||
return;
|
||||
}
|
||||
this.state.reasonSubmitting = true;
|
||||
try {
|
||||
await rpc("/fusion_clock/submit_reason", {
|
||||
reason: this.state.reasonText.trim(),
|
||||
departure_time: this.state.reasonTime || "",
|
||||
});
|
||||
this.state.showReasonDialog = false;
|
||||
this.state.reasonText = "";
|
||||
this.state.reasonTime = "";
|
||||
this.state.reasonSubmitting = false;
|
||||
await this._executeClockAction();
|
||||
} catch (e) {
|
||||
this.state.error = "Failed to submit reason.";
|
||||
this.state.reasonSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
get confirmCheckinDisplay() {
|
||||
if (!this.state.checkInTime) return "--";
|
||||
const d = this.state.checkInTime;
|
||||
let h = d.getHours();
|
||||
const m = d.getMinutes();
|
||||
const ampm = h >= 12 ? "PM" : "AM";
|
||||
h = h % 12 || 12;
|
||||
return h + ":" + (m < 10 ? "0" : "") + m + " " + ampm;
|
||||
}
|
||||
|
||||
get confirmDurationDisplay() {
|
||||
if (!this.state.checkInTime) return "--";
|
||||
const diff = Math.max(0, Math.floor((new Date() - this.state.checkInTime) / 1000));
|
||||
const dh = Math.floor(diff / 3600);
|
||||
const dm = Math.floor((diff % 3600) / 60);
|
||||
return dh + "h " + dm + "m";
|
||||
}
|
||||
|
||||
_startTimer() {
|
||||
this._stopTimer();
|
||||
this._updateTimer();
|
||||
|
||||
Reference in New Issue
Block a user