This commit is contained in:
gsinghpal
2026-02-27 14:32:32 -05:00
parent b649246e81
commit b925766966
80 changed files with 7831 additions and 1041 deletions

View File

@@ -357,6 +357,52 @@ html.o_dark .fclk-app,
font-size: 12px;
}
/* ---- Request Leave Button ---- */
.fclk-leave-btn {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
background: var(--fclk-card);
border: 1px solid var(--fclk-card-border);
border-radius: 14px;
padding: 16px 20px;
margin-bottom: 28px;
color: var(--fclk-text);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: var(--fclk-shadow);
text-align: left;
font-family: inherit;
}
.fclk-leave-btn svg:first-child {
color: var(--fclk-green);
flex-shrink: 0;
}
.fclk-leave-btn-arrow {
margin-left: auto;
color: var(--fclk-text-dim);
flex-shrink: 0;
transition: transform 0.2s ease;
}
.fclk-leave-btn:hover {
background: var(--fclk-hover-bg);
border-color: rgba(16, 185, 129, 0.3);
}
.fclk-leave-btn:hover .fclk-leave-btn-arrow {
transform: translateX(2px);
}
.fclk-leave-btn:active {
transform: scale(0.99);
}
/* ---- Recent Activity ---- */
.fclk-recent-section {
margin-bottom: 24px;
@@ -486,7 +532,7 @@ html.o_dark .fclk-app,
text-decoration: none;
}
/* ---- Modal ---- */
/* ---- Legacy Modal (location picker still uses this) ---- */
.fclk-modal {
position: fixed;
top: 0;
@@ -533,6 +579,300 @@ html.o_dark .fclk-app,
to { transform: translateY(0); }
}
/* ============================================================
Wizard Dialogs - Professional modals for reasons, confirmations
Theme-aware, works in both light and dark mode
============================================================ */
/* Standalone fallbacks for wizard modals rendered outside .fclk-app */
.fclk-wizard-overlay {
--fclk-card: var(--fclk-card, #ffffff);
--fclk-card-border: var(--fclk-card-border, #e5e7eb);
--fclk-bg: var(--fclk-bg, #f3f4f6);
--fclk-text: var(--fclk-text, #1f2937);
--fclk-text-muted: var(--fclk-text-muted, #6b7280);
--fclk-text-dim: var(--fclk-text-dim, #9ca3af);
--fclk-green: var(--fclk-green, #10B981);
--fclk-green-glow: var(--fclk-green-glow, rgba(16, 185, 129, 0.25));
--fclk-hover-bg: var(--fclk-hover-bg, #f9fafb);
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
@media (prefers-color-scheme: dark) {
.fclk-wizard-overlay {
--fclk-card: #1a1d23;
--fclk-card-border: #2a2d35;
--fclk-bg: #0f1117;
--fclk-text: #ffffff;
--fclk-text-muted: #9ca3af;
--fclk-text-dim: #6b7280;
--fclk-green-glow: rgba(16, 185, 129, 0.3);
--fclk-hover-bg: #1e2128;
}
}
html.o_dark .fclk-wizard-overlay {
--fclk-card: #1a1d23;
--fclk-card-border: #2a2d35;
--fclk-bg: #0f1117;
--fclk-text: #ffffff;
--fclk-text-muted: #9ca3af;
--fclk-text-dim: #6b7280;
--fclk-green-glow: rgba(16, 185, 129, 0.3);
--fclk-hover-bg: #1e2128;
}
.fclk-wizard-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.fclk-wizard-dialog {
position: relative;
background: var(--fclk-card);
border: 1px solid var(--fclk-card-border);
border-radius: 20px;
width: 100%;
max-width: 440px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.05);
animation: fclk-wizard-enter 0.3s cubic-bezier(0.32, 0.72, 0, 1);
}
.fclk-wizard-dialog--compact {
max-width: 380px;
}
@keyframes fclk-wizard-enter {
from {
opacity: 0;
transform: scale(0.95) translateY(8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.fclk-wizard-header {
padding: 28px 24px 20px;
text-align: center;
border-bottom: 1px solid var(--fclk-card-border);
}
.fclk-wizard-header-icon {
width: 56px;
height: 56px;
border-radius: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.fclk-wizard-header--warning .fclk-wizard-header-icon {
background: rgba(245, 158, 11, 0.12);
color: #f59e0b;
}
.fclk-wizard-header--danger .fclk-wizard-header-icon {
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
}
.fclk-wizard-header--info .fclk-wizard-header-icon {
background: rgba(59, 130, 246, 0.12);
color: #3b82f6;
}
.fclk-wizard-title {
color: var(--fclk-text);
font-size: 20px;
font-weight: 700;
margin: 0 0 6px;
letter-spacing: -0.3px;
}
.fclk-wizard-subtitle {
color: var(--fclk-text-muted);
font-size: 13px;
line-height: 1.5;
margin: 0;
}
.fclk-wizard-body {
padding: 24px;
}
.fclk-wizard-field {
margin-bottom: 20px;
}
.fclk-wizard-field:last-child {
margin-bottom: 0;
}
.fclk-wizard-label {
display: flex;
align-items: center;
gap: 6px;
color: var(--fclk-text);
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
}
.fclk-wizard-label svg {
color: var(--fclk-text-muted);
flex-shrink: 0;
}
.fclk-wizard-required {
color: #ef4444;
font-weight: 700;
}
.fclk-wizard-input {
width: 100%;
background: var(--fclk-bg);
border: 1.5px solid var(--fclk-card-border);
border-radius: 12px;
padding: 12px 14px;
font-size: 14px;
color: var(--fclk-text);
transition: border-color 0.2s, box-shadow 0.2s;
outline: none;
font-family: inherit;
}
.fclk-wizard-input:focus {
border-color: var(--fclk-green);
box-shadow: 0 0 0 3px var(--fclk-green-glow);
}
.fclk-wizard-input::placeholder {
color: var(--fclk-text-dim);
}
.fclk-wizard-textarea {
resize: vertical;
min-height: 80px;
}
.fclk-wizard-hint {
display: block;
color: var(--fclk-text-dim);
font-size: 11px;
margin-top: 6px;
}
.fclk-wizard-footer {
padding: 16px 24px 20px;
display: flex;
gap: 10px;
justify-content: flex-end;
border-top: 1px solid var(--fclk-card-border);
}
.fclk-wizard-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.2s ease;
letter-spacing: 0.2px;
}
.fclk-wizard-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.fclk-wizard-btn--primary {
background: linear-gradient(135deg, #10B981, #059669);
color: #fff;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
}
.fclk-wizard-btn--primary:hover:not(:disabled) {
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4);
transform: translateY(-1px);
}
.fclk-wizard-btn--danger {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: #fff;
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
}
.fclk-wizard-btn--danger:hover:not(:disabled) {
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.4);
transform: translateY(-1px);
}
.fclk-wizard-btn--secondary {
background: var(--fclk-bg);
color: var(--fclk-text-muted);
border: 1px solid var(--fclk-card-border);
}
.fclk-wizard-btn--secondary:hover:not(:disabled) {
background: var(--fclk-hover-bg);
color: var(--fclk-text);
}
/* Clock-out confirmation summary card */
.fclk-clockout-summary {
background: var(--fclk-bg);
border: 1px solid var(--fclk-card-border);
border-radius: 12px;
padding: 16px;
}
.fclk-clockout-summary-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.fclk-clockout-summary-row + .fclk-clockout-summary-row {
border-top: 1px solid var(--fclk-card-border);
}
.fclk-clockout-summary-label {
color: var(--fclk-text-muted);
font-size: 13px;
}
.fclk-clockout-summary-value {
color: var(--fclk-text);
font-size: 14px;
font-weight: 600;
}
.fclk-modal-list {
display: flex;
flex-direction: column;

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

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

View 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"],
});

View 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"],
});

View File

@@ -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
// =========================================================================

View File

@@ -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;

View File

@@ -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();

View File

@@ -376,3 +376,437 @@ $fclk-gradient-active: linear-gradient(135deg, $fclk-green 0%, $fclk-teal 100%);
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
// ===========================================================
// FAB Dialog Overlays (reason, clock-out confirmation)
// ===========================================================
.fclk-fab-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.fclk-fab-dialog-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(6px);
}
.fclk-fab-dialog {
position: relative;
width: 100%;
max-width: 420px;
background: var(--fclk-fab-panel-bg);
border: 1px solid var(--fclk-fab-panel-border);
border-radius: 20px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.05);
animation: fclk-dialog-enter 0.3s cubic-bezier(0.32, 0.72, 0, 1);
max-height: 85vh;
overflow-y: auto;
&.fclk-fab-dialog--compact {
max-width: 360px;
}
}
@keyframes fclk-dialog-enter {
from {
opacity: 0;
transform: scale(0.95) translateY(8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.fclk-fab-dialog-header {
padding: 28px 24px 20px;
text-align: center;
border-bottom: 1px solid var(--fclk-fab-panel-border);
}
.fclk-fab-dialog-icon {
width: 52px;
height: 52px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 14px;
font-size: 22px;
}
.fclk-fab-dialog-header--warning .fclk-fab-dialog-icon {
background: rgba(245, 158, 11, 0.12);
color: #f59e0b;
}
.fclk-fab-dialog-header--danger .fclk-fab-dialog-icon {
background: rgba($fclk-red, 0.12);
color: $fclk-red;
}
.fclk-fab-dialog-title {
color: var(--fclk-fab-text);
font-size: 18px;
font-weight: 700;
margin: 0 0 6px;
letter-spacing: -0.3px;
}
.fclk-fab-dialog-subtitle {
color: var(--fclk-fab-muted);
font-size: 12px;
line-height: 1.5;
margin: 0;
}
.fclk-fab-dialog-body {
padding: 20px 24px;
}
.fclk-fab-dialog-field {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.fclk-fab-dialog-label {
display: flex;
align-items: center;
gap: 6px;
color: var(--fclk-fab-text);
font-size: 12px;
font-weight: 600;
margin-bottom: 6px;
.fa { color: var(--fclk-fab-muted); font-size: 13px; }
}
.fclk-fab-dialog-required {
color: $fclk-red;
font-weight: 700;
}
.fclk-fab-dialog-input {
width: 100%;
background: var(--fclk-fab-location-bg, rgba(0, 0, 0, 0.04));
border: 1.5px solid var(--fclk-fab-panel-border);
border-radius: 10px;
padding: 10px 12px;
font-size: 13px;
color: var(--fclk-fab-text);
font-family: inherit;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
resize: vertical;
&:focus {
border-color: $fclk-green;
box-shadow: 0 0 0 3px rgba($fclk-green, 0.15);
}
&::placeholder {
color: var(--fclk-fab-muted);
}
}
.fclk-fab-dialog-hint {
display: block;
color: var(--fclk-fab-muted);
font-size: 10px;
margin-top: 4px;
}
.fclk-fab-dialog-footer {
padding: 14px 24px 18px;
display: flex;
gap: 10px;
justify-content: flex-end;
border-top: 1px solid var(--fclk-fab-panel-border);
}
.fclk-fab-dialog-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 18px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.2s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.fa { font-size: 13px; }
}
.fclk-fab-dialog-btn--cancel {
background: transparent;
color: var(--fclk-fab-muted);
border: 1px solid var(--fclk-fab-panel-border);
&:hover:not(:disabled) {
background: var(--fclk-fab-location-bg);
color: var(--fclk-fab-text);
}
}
.fclk-fab-dialog-btn--submit {
background: $fclk-gradient-active;
color: #fff;
box-shadow: 0 2px 8px rgba($fclk-green, 0.3);
&:hover:not(:disabled) {
box-shadow: 0 4px 16px rgba($fclk-green, 0.4);
transform: translateY(-1px);
}
}
.fclk-fab-dialog-btn--danger {
background: $fclk-red;
color: #fff;
box-shadow: 0 2px 8px rgba($fclk-red, 0.3);
&:hover:not(:disabled) {
box-shadow: 0 4px 16px rgba($fclk-red, 0.4);
transform: translateY(-1px);
}
}
// Summary card (used in clock-out confirmation)
.fclk-fab-dialog-summary {
background: var(--fclk-fab-location-bg, rgba(0, 0, 0, 0.04));
border: 1px solid var(--fclk-fab-panel-border);
border-radius: 10px;
padding: 14px;
}
.fclk-fab-dialog-summary-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
+ .fclk-fab-dialog-summary-row {
border-top: 1px solid var(--fclk-fab-panel-border);
}
}
.fclk-fab-dialog-summary-label {
color: var(--fclk-fab-muted);
font-size: 12px;
}
.fclk-fab-dialog-summary-value {
color: var(--fclk-fab-text);
font-size: 13px;
font-weight: 600;
}
// ===========================================================
// Location Map Widget
// ===========================================================
.fclk-map-widget {
width: 100%;
margin: 8px 0;
}
.fclk-map-container {
display: block;
border: 1px solid var(--fclk-fab-panel-border, #e5e7eb);
}
.fclk-map-loading,
.fclk-map-error,
.fclk-map-placeholder {
display: flex;
align-items: center;
gap: 8px;
padding: 20px 16px;
font-size: 13px;
border-radius: 8px;
border: 1px dashed var(--fclk-fab-panel-border, #e5e7eb);
}
.fclk-map-loading {
color: var(--fclk-fab-muted, #6b7280);
background: rgba(59, 130, 246, 0.04);
}
.fclk-map-error {
color: $fclk-red;
background: rgba($fclk-red, 0.04);
}
.fclk-map-placeholder {
color: var(--fclk-fab-muted, #6b7280);
background: rgba(0, 0, 0, 0.02);
}
html.o_dark {
.fclk-map-loading { background: rgba(59, 130, 246, 0.08); }
.fclk-map-error { background: rgba($fclk-red, 0.08); }
.fclk-map-placeholder { background: rgba(255, 255, 255, 0.03); }
}
.fclk-map-hint {
text-align: center;
padding: 6px 12px;
font-size: 11px;
color: var(--fclk-fab-muted, #6b7280);
.fa { margin-right: 4px; }
}
// Google Places dropdown z-index fix
.pac-container {
z-index: 2100 !important;
border-radius: 8px;
margin-top: 4px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
font-family: inherit;
}
.fclk-places-input {
width: 100%;
}
// ===========================================================
// Dashboard Summary Cards
// ===========================================================
.fclk-dash-card {
position: relative;
border-radius: 12px;
padding: 20px;
text-align: center;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
}
.fclk-dash-card-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 20px;
margin-bottom: 12px;
}
.fclk-dash-card-value {
font-size: 32px;
font-weight: 700;
line-height: 1;
margin-bottom: 4px;
}
.fclk-dash-card-label {
font-size: 13px;
font-weight: 500;
letter-spacing: 0.2px;
}
// -- Total (blue/slate) --
.fclk-dash-card--total {
background: linear-gradient(135deg, #eff6ff 0%, #e0e7ff 100%);
border: 1px solid #bfdbfe;
.fclk-dash-card-icon { background: rgba(59, 130, 246, 0.15); color: #2563eb; }
.fclk-dash-card-value { color: #1e3a5f; }
.fclk-dash-card-label { color: #3b82f6; }
}
// -- Present (green) --
.fclk-dash-card--present {
background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%);
border: 1px solid #a7f3d0;
.fclk-dash-card-icon { background: rgba(16, 185, 129, 0.15); color: #059669; }
.fclk-dash-card-value { color: #064e3b; }
.fclk-dash-card-label { color: #10b981; }
}
// -- Absent (red) --
.fclk-dash-card--absent {
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
border: 1px solid #fecaca;
.fclk-dash-card-icon { background: rgba(239, 68, 68, 0.12); color: #dc2626; }
.fclk-dash-card-value { color: #7f1d1d; }
.fclk-dash-card-label { color: #ef4444; }
}
// -- Late (amber) --
.fclk-dash-card--late {
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
border: 1px solid #fde68a;
.fclk-dash-card-icon { background: rgba(245, 158, 11, 0.15); color: #d97706; }
.fclk-dash-card-value { color: #78350f; }
.fclk-dash-card-label { color: #f59e0b; }
}
// -- Dark mode overrides --
html.o_dark {
.fclk-dash-card--total {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(99, 102, 241, 0.1) 100%);
border-color: rgba(59, 130, 246, 0.25);
.fclk-dash-card-value { color: #93c5fd; }
.fclk-dash-card-label { color: #60a5fa; }
.fclk-dash-card-icon { background: rgba(59, 130, 246, 0.2); color: #60a5fa; }
}
.fclk-dash-card--present {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(52, 211, 153, 0.08) 100%);
border-color: rgba(16, 185, 129, 0.25);
.fclk-dash-card-value { color: #6ee7b7; }
.fclk-dash-card-label { color: #34d399; }
.fclk-dash-card-icon { background: rgba(16, 185, 129, 0.2); color: #34d399; }
}
.fclk-dash-card--absent {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(248, 113, 113, 0.08) 100%);
border-color: rgba(239, 68, 68, 0.25);
.fclk-dash-card-value { color: #fca5a5; }
.fclk-dash-card-label { color: #f87171; }
.fclk-dash-card-icon { background: rgba(239, 68, 68, 0.18); color: #f87171; }
}
.fclk-dash-card--late {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.1) 0%, rgba(251, 191, 36, 0.08) 100%);
border-color: rgba(245, 158, 11, 0.25);
.fclk-dash-card-value { color: #fcd34d; }
.fclk-dash-card-label { color: #fbbf24; }
.fclk-dash-card-icon { background: rgba(245, 158, 11, 0.2); color: #fbbf24; }
}
.fclk-dash-card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
}

View File

@@ -0,0 +1,155 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<t t-name="fusion_clock.Dashboard">
<div class="o_action">
<div class="container-fluid py-3">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">Fusion Clock Dashboard</h2>
<button class="btn btn-outline-primary" t-on-click="onRefresh">
<i class="fa fa-refresh"/> Refresh
</button>
</div>
<t t-if="state.loading">
<div class="text-center py-5">
<i class="fa fa-spinner fa-spin fa-2x"/>
<p class="mt-2">Loading dashboard...</p>
</div>
</t>
<t t-if="state.error">
<div class="alert alert-danger">
<t t-esc="state.error"/>
</div>
</t>
<t t-if="!state.loading and !state.error">
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="fclk-dash-card fclk-dash-card--total">
<div class="fclk-dash-card-icon">
<i class="fa fa-users"/>
</div>
<div class="fclk-dash-card-value"><t t-esc="state.total_employees"/></div>
<div class="fclk-dash-card-label">Total Employees</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="fclk-dash-card fclk-dash-card--present">
<div class="fclk-dash-card-icon">
<i class="fa fa-check-circle"/>
</div>
<div class="fclk-dash-card-value"><t t-esc="state.present_count"/></div>
<div class="fclk-dash-card-label">Present Today</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="fclk-dash-card fclk-dash-card--absent">
<div class="fclk-dash-card-icon">
<i class="fa fa-times-circle"/>
</div>
<div class="fclk-dash-card-value"><t t-esc="state.absent_count"/></div>
<div class="fclk-dash-card-label">Absent Today</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="fclk-dash-card fclk-dash-card--late">
<div class="fclk-dash-card-icon">
<i class="fa fa-clock-o"/>
</div>
<div class="fclk-dash-card-value"><t t-esc="state.late_count"/></div>
<div class="fclk-dash-card-label">Late Today</div>
</div>
</div>
</div>
<div class="row">
<!-- Currently Clocked In -->
<div class="col-md-8 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Currently Clocked In</h5>
<span class="badge bg-success"><t t-esc="state.clocked_in.length"/> active</span>
</div>
<div class="card-body p-0">
<t t-if="state.clocked_in.length === 0">
<div class="text-center py-4 text-muted">
No employees currently clocked in
</div>
</t>
<t t-else="">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Employee</th>
<th>Clock-In</th>
<th>Location</th>
</tr>
</thead>
<tbody>
<t t-foreach="state.clocked_in" t-as="emp" t-key="emp_index">
<tr>
<td><t t-esc="emp.employee"/></td>
<td><t t-esc="emp.check_in"/></td>
<td><t t-esc="emp.location"/></td>
</tr>
</t>
</tbody>
</table>
</t>
</div>
</div>
</div>
<!-- Alerts Panel -->
<div class="col-md-4 mb-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Alerts</h5>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3 cursor-pointer"
t-on-click="onViewActivityLogs">
<span><i class="fa fa-exclamation-circle text-warning me-2"/>Pending Reasons</span>
<span class="badge bg-warning"><t t-esc="state.pending_reasons"/></span>
</div>
<div class="d-flex justify-content-between align-items-center mb-3 cursor-pointer"
t-on-click="onViewCorrections">
<span><i class="fa fa-edit text-info me-2"/>Pending Corrections</span>
<span class="badge bg-info"><t t-esc="state.pending_corrections"/></span>
</div>
<div class="d-flex justify-content-between align-items-center cursor-pointer"
t-on-click="onViewPenalties">
<span><i class="fa fa-clock-o text-danger me-2"/>Late Today</span>
<span class="badge bg-danger"><t t-esc="state.late_count"/></span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">Quick Actions</h5>
</div>
<div class="card-body">
<button class="btn btn-outline-primary w-100 mb-2" t-on-click="onViewAttendances">
<i class="fa fa-list me-1"/> View All Attendances
</button>
<button class="btn btn-outline-secondary w-100" t-on-click="onViewActivityLogs">
<i class="fa fa-history me-1"/> Activity Logs
</button>
</div>
</div>
</div>
</div>
</t>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<!-- Interactive Map Widget -->
<t t-name="fusion_clock.LocationMap">
<div class="fclk-map-widget">
<div t-if="state.loading" class="fclk-map-loading">
<i class="fa fa-circle-o-notch fa-spin"/> Loading map...
</div>
<div t-if="state.error" class="fclk-map-error">
<i class="fa fa-exclamation-triangle"/> <t t-esc="state.error"/>
</div>
<!-- ALWAYS in the DOM so the ref is available at mount time.
Hidden via inline display:none until the map is ready. -->
<div t-ref="mapContainer"
class="fclk-map-container"
t-att-style="state.mapVisible ? 'width:100%;height:400px;border-radius:8px;' : 'display:none;'"/>
<div t-if="state.mapVisible and !props.readonly" class="fclk-map-hint">
<i class="fa fa-hand-pointer-o"/> Click the map or drag the marker to fine-tune the location
</div>
</div>
</t>
<!-- Places Autocomplete Widget -->
<t t-name="fusion_clock.PlacesAutocomplete">
<t t-if="isReadonly">
<span t-esc="props.record.data[props.name] || ''"/>
</t>
<t t-else="">
<input t-ref="addressInput"
type="text"
class="o_input fclk-places-input"
t-att-value="state.value"
t-on-input="onInput"
t-on-change="onChange"
placeholder="Start typing an address..."/>
</t>
</t>
</templates>

View File

@@ -68,20 +68,91 @@
<!-- 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>
<!-- Missed Clock-Out Reason Dialog -->
<div t-if="state.showReasonDialog" class="fclk-fab-dialog-overlay">
<div class="fclk-fab-dialog-backdrop" t-on-click="cancelReason"/>
<div class="fclk-fab-dialog">
<div class="fclk-fab-dialog-header fclk-fab-dialog-header--warning">
<div class="fclk-fab-dialog-icon">
<i class="fa fa-exclamation-triangle"/>
</div>
<h4 class="fclk-fab-dialog-title">Missed Clock-Out</h4>
<p class="fclk-fab-dialog-subtitle">You didn't clock out on your last shift. Please provide details before continuing.</p>
</div>
<div class="fclk-fab-dialog-body">
<div class="fclk-fab-dialog-field">
<label class="fclk-fab-dialog-label">
<i class="fa fa-comment-o"/> Reason <span class="fclk-fab-dialog-required">*</span>
</label>
<textarea class="fclk-fab-dialog-input" rows="3"
placeholder="Please explain why you didn't clock out..."
t-on-input="onReasonTextInput"
t-att-value="state.reasonText"/>
</div>
<div class="fclk-fab-dialog-field">
<label class="fclk-fab-dialog-label">
<i class="fa fa-clock-o"/> Departure Time
</label>
<input type="datetime-local" class="fclk-fab-dialog-input"
t-on-input="onReasonTimeInput"
t-att-value="state.reasonTime"/>
<span class="fclk-fab-dialog-hint">When did you actually leave? (optional)</span>
</div>
</div>
<div class="fclk-fab-dialog-footer">
<button class="fclk-fab-dialog-btn fclk-fab-dialog-btn--cancel" t-on-click="cancelReason">Cancel</button>
<button class="fclk-fab-dialog-btn fclk-fab-dialog-btn--submit" t-on-click="submitReason"
t-att-disabled="state.reasonSubmitting">
<t t-if="state.reasonSubmitting"><i class="fa fa-circle-o-notch fa-spin"/> Submitting...</t>
<t t-else=""><i class="fa fa-check"/> Submit Reason</t>
</button>
</div>
</div>
</div>
<!-- Clock-Out Confirmation Dialog -->
<div t-if="state.showClockoutConfirm" class="fclk-fab-dialog-overlay">
<div class="fclk-fab-dialog-backdrop" t-on-click="cancelClockOut"/>
<div class="fclk-fab-dialog fclk-fab-dialog--compact">
<div class="fclk-fab-dialog-header fclk-fab-dialog-header--danger">
<div class="fclk-fab-dialog-icon">
<i class="fa fa-stop-circle"/>
</div>
<h4 class="fclk-fab-dialog-title">Clock Out?</h4>
<p class="fclk-fab-dialog-subtitle">Are you sure you want to end your current shift?</p>
</div>
<div class="fclk-fab-dialog-body">
<div class="fclk-fab-dialog-summary">
<div class="fclk-fab-dialog-summary-row">
<span class="fclk-fab-dialog-summary-label">Clocked in at</span>
<span class="fclk-fab-dialog-summary-value" t-esc="confirmCheckinDisplay"/>
</div>
<div class="fclk-fab-dialog-summary-row">
<span class="fclk-fab-dialog-summary-label">Duration</span>
<span class="fclk-fab-dialog-summary-value" t-esc="confirmDurationDisplay"/>
</div>
</div>
</div>
<div class="fclk-fab-dialog-footer">
<button class="fclk-fab-dialog-btn fclk-fab-dialog-btn--cancel" t-on-click="cancelClockOut">Cancel</button>
<button class="fclk-fab-dialog-btn fclk-fab-dialog-btn--danger" t-on-click="confirmClockOut">
<i class="fa fa-stop-circle-o"/> Confirm Clock Out
</button>
</div>
</div>
</div>
</div>
</t>