changes
This commit is contained in:
@@ -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;
|
||||
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
155
fusion_clock/static/src/xml/fusion_clock_dashboard.xml
Normal file
155
fusion_clock/static/src/xml/fusion_clock_dashboard.xml
Normal 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>
|
||||
40
fusion_clock/static/src/xml/fusion_clock_location.xml
Normal file
40
fusion_clock/static/src/xml/fusion_clock_location.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user