Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
/**
* Fusion Authorizer Portal - Assessment Form
*/
odoo.define('fusion_authorizer_portal.assessment_form', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
publicWidget.registry.AssessmentForm = publicWidget.Widget.extend({
selector: '#assessment-form',
events: {
'change input, change select, change textarea': '_onFieldChange',
'submit': '_onSubmit',
},
init: function () {
this._super.apply(this, arguments);
this.hasUnsavedChanges = false;
},
start: function () {
this._super.apply(this, arguments);
this._initializeForm();
return Promise.resolve();
},
_initializeForm: function () {
var self = this;
// Warn before leaving with unsaved changes
window.addEventListener('beforeunload', function (e) {
if (self.hasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
return '';
}
});
// Auto-fill full name from first + last name
var firstNameInput = this.el.querySelector('[name="client_first_name"]');
var lastNameInput = this.el.querySelector('[name="client_last_name"]');
var fullNameInput = this.el.querySelector('[name="client_name"]');
if (firstNameInput && lastNameInput && fullNameInput) {
var updateFullName = function () {
var first = firstNameInput.value.trim();
var last = lastNameInput.value.trim();
if (first || last) {
fullNameInput.value = (first + ' ' + last).trim();
}
};
firstNameInput.addEventListener('blur', updateFullName);
lastNameInput.addEventListener('blur', updateFullName);
}
// Number input validation
var numberInputs = this.el.querySelectorAll('input[type="number"]');
numberInputs.forEach(function (input) {
input.addEventListener('input', function () {
var value = parseFloat(this.value);
var min = parseFloat(this.min) || 0;
var max = parseFloat(this.max) || 9999;
if (value < min) this.value = min;
if (value > max) this.value = max;
});
});
},
_onFieldChange: function (ev) {
this.hasUnsavedChanges = true;
// Visual feedback that form has changes
var saveBtn = this.el.querySelector('button[value="save"]');
if (saveBtn) {
saveBtn.classList.add('btn-warning');
saveBtn.classList.remove('btn-primary');
}
},
_onSubmit: function (ev) {
// Validate required fields
var requiredFields = this.el.querySelectorAll('[required]');
var isValid = true;
requiredFields.forEach(function (field) {
if (!field.value.trim()) {
field.classList.add('is-invalid');
isValid = false;
} else {
field.classList.remove('is-invalid');
}
});
if (!isValid) {
ev.preventDefault();
alert('Please fill in all required fields.');
return false;
}
this.hasUnsavedChanges = false;
return true;
}
});
return publicWidget.registry.AssessmentForm;
});

View File

@@ -0,0 +1,37 @@
/** @odoo-module **/
// Fusion Authorizer Portal - Message Authorizer Chatter Button
// Copyright 2026 Nexa Systems Inc.
// License OPL-1
//
// Patches the Chatter component to add a "Message Authorizer" button
// that opens the mail composer targeted at the assigned authorizer.
import { Chatter } from "@mail/chatter/web_portal/chatter";
import { patch } from "@web/core/utils/patch";
import { useService } from "@web/core/utils/hooks";
patch(Chatter.prototype, {
setup() {
super.setup(...arguments);
this._fapActionService = useService("action");
this._fapOrm = useService("orm");
},
async onClickMessageAuthorizer() {
const thread = this.state.thread;
if (!thread || thread.model !== "sale.order") return;
try {
const result = await this._fapOrm.call(
"sale.order",
"action_message_authorizer",
[thread.id],
);
if (result && result.type === "ir.actions.act_window") {
this._fapActionService.doAction(result);
}
} catch (e) {
console.warn("Message Authorizer action failed:", e);
}
},
});

View File

@@ -0,0 +1,478 @@
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
publicWidget.registry.LoanerPortal = publicWidget.Widget.extend({
selector: '#loanerSection, #btn_checkout_loaner, .btn-loaner-return',
start: function () {
this._super.apply(this, arguments);
this._allProducts = [];
this._initLoanerSection();
this._initCheckoutButton();
this._initReturnButtons();
this._initModal();
},
// =====================================================================
// MODAL: Initialize and wire up the loaner checkout modal
// =====================================================================
_initModal: function () {
var self = this;
var modal = document.getElementById('loanerCheckoutModal');
if (!modal) return;
var categorySelect = document.getElementById('modal_category_id');
var productSelect = document.getElementById('modal_product_id');
var lotSelect = document.getElementById('modal_lot_id');
var loanDays = document.getElementById('modal_loan_days');
var btnCheckout = document.getElementById('modal_btn_checkout');
var btnCreateProduct = document.getElementById('modal_btn_create_product');
var newCategorySelect = document.getElementById('modal_new_category_id');
var createResult = document.getElementById('modal_create_result');
// Load categories when modal opens
modal.addEventListener('show.bs.modal', function () {
self._loadCategories(categorySelect, newCategorySelect);
self._loadProducts(null, productSelect, lotSelect);
});
// Category change -> filter products
if (categorySelect) {
categorySelect.addEventListener('change', function () {
var catId = this.value ? parseInt(this.value) : null;
self._filterProducts(catId, productSelect, lotSelect);
});
}
// Product change -> filter lots
if (productSelect) {
productSelect.addEventListener('change', function () {
var prodId = this.value ? parseInt(this.value) : null;
self._filterLots(prodId, lotSelect, loanDays);
});
}
// Quick Create Product
if (btnCreateProduct) {
btnCreateProduct.addEventListener('click', function () {
var name = document.getElementById('modal_new_product_name').value.trim();
var serial = document.getElementById('modal_new_serial').value.trim();
var catId = newCategorySelect ? newCategorySelect.value : '';
if (!name || !serial) {
alert('Please enter both product name and serial number.');
return;
}
btnCreateProduct.disabled = true;
btnCreateProduct.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Creating...';
self._rpc('/my/loaner/create-product', {
product_name: name,
serial_number: serial,
category_id: catId || null,
}).then(function (result) {
if (result.success) {
// Add to product dropdown
var opt = document.createElement('option');
opt.value = result.product_id;
opt.text = result.product_name;
opt.selected = true;
productSelect.appendChild(opt);
// Add to lots
lotSelect.innerHTML = '';
var lotOpt = document.createElement('option');
lotOpt.value = result.lot_id;
lotOpt.text = result.lot_name;
lotOpt.selected = true;
lotSelect.appendChild(lotOpt);
// Add to internal data
self._allProducts.push({
id: result.product_id,
name: result.product_name,
category_id: catId ? parseInt(catId) : null,
period_days: 7,
lots: [{ id: result.lot_id, name: result.lot_name }],
});
if (createResult) {
createResult.style.display = '';
createResult.innerHTML = '<div class="alert alert-success py-2"><i class="fa fa-check me-1"></i> "' + result.product_name + '" (S/N: ' + result.lot_name + ') created!</div>';
}
// Clear fields
document.getElementById('modal_new_product_name').value = '';
document.getElementById('modal_new_serial').value = '';
} else {
if (createResult) {
createResult.style.display = '';
createResult.innerHTML = '<div class="alert alert-danger py-2">' + (result.error || 'Error') + '</div>';
}
}
btnCreateProduct.disabled = false;
btnCreateProduct.innerHTML = '<i class="fa fa-plus me-1"></i> Create Product';
});
});
}
// Checkout button
if (btnCheckout) {
btnCheckout.addEventListener('click', function () {
var productId = productSelect.value ? parseInt(productSelect.value) : null;
var lotId = lotSelect.value ? parseInt(lotSelect.value) : null;
var days = parseInt(loanDays.value) || 7;
var orderId = document.getElementById('modal_order_id').value;
var clientId = document.getElementById('modal_client_id').value;
if (!productId) {
alert('Please select a product.');
return;
}
btnCheckout.disabled = true;
btnCheckout.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Processing...';
self._rpc('/my/loaner/checkout', {
product_id: productId,
lot_id: lotId,
sale_order_id: orderId ? parseInt(orderId) : null,
client_id: clientId ? parseInt(clientId) : null,
loaner_period_days: days,
checkout_condition: 'good',
checkout_notes: '',
}).then(function (result) {
if (result.success) {
self._hideModal(modal);
alert(result.message);
location.reload();
} else {
alert('Error: ' + (result.error || 'Unknown'));
btnCheckout.disabled = false;
btnCheckout.innerHTML = '<i class="fa fa-check me-1"></i> Checkout Loaner';
}
});
});
}
},
_loadCategories: function (categorySelect, newCategorySelect) {
this._rpc('/my/loaner/categories', {}).then(function (categories) {
categories = categories || [];
// Main category dropdown
if (categorySelect) {
categorySelect.innerHTML = '<option value="">All Categories</option>';
categories.forEach(function (c) {
var opt = document.createElement('option');
opt.value = c.id;
opt.text = c.name;
categorySelect.appendChild(opt);
});
}
// Quick create category dropdown
if (newCategorySelect) {
newCategorySelect.innerHTML = '<option value="">-- Select --</option>';
categories.forEach(function (c) {
var opt = document.createElement('option');
opt.value = c.id;
opt.text = c.name;
newCategorySelect.appendChild(opt);
});
}
});
},
_loadProducts: function (categoryId, productSelect, lotSelect) {
var self = this;
var params = {};
if (categoryId) params.category_id = categoryId;
this._rpc('/my/loaner/products', params).then(function (products) {
self._allProducts = products || [];
self._renderProducts(self._allProducts, productSelect);
if (lotSelect) lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
});
},
_filterProducts: function (categoryId, productSelect, lotSelect) {
var filtered = this._allProducts;
if (categoryId) {
filtered = this._allProducts.filter(function (p) { return p.category_id === categoryId; });
}
this._renderProducts(filtered, productSelect);
if (lotSelect) lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
},
_renderProducts: function (products, productSelect) {
if (!productSelect) return;
productSelect.innerHTML = '<option value="">-- Select Product --</option>';
products.forEach(function (p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.text = p.name + ' (' + p.lots.length + ' avail)';
productSelect.appendChild(opt);
});
},
_filterLots: function (productId, lotSelect, loanDays) {
if (!lotSelect) return;
lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
if (!productId) return;
var product = this._allProducts.find(function (p) { return p.id === productId; });
if (product) {
product.lots.forEach(function (lot) {
var opt = document.createElement('option');
opt.value = lot.id;
opt.text = lot.name;
lotSelect.appendChild(opt);
});
if (loanDays && product.period_days) {
loanDays.value = product.period_days;
}
}
},
// =====================================================================
// CHECKOUT BUTTON: Opens the modal
// =====================================================================
_initCheckoutButton: function () {
var self = this;
var btns = document.querySelectorAll('#btn_checkout_loaner');
btns.forEach(function (btn) {
btn.addEventListener('click', function () {
var orderId = btn.dataset.orderId || '';
var clientId = btn.dataset.clientId || '';
// Set context in modal
var modalOrderId = document.getElementById('modal_order_id');
var modalClientId = document.getElementById('modal_client_id');
if (modalOrderId) modalOrderId.value = orderId;
if (modalClientId) modalClientId.value = clientId;
// Show modal
var modal = document.getElementById('loanerCheckoutModal');
self._showModal(modal);
});
});
},
// =====================================================================
// RETURN BUTTONS
// =====================================================================
_initReturnButtons: function () {
var self = this;
var returnModal = document.getElementById('loanerReturnModal');
if (!returnModal) return;
var btnSubmitReturn = document.getElementById('return_modal_btn_submit');
document.querySelectorAll('.btn-loaner-return').forEach(function (btn) {
btn.addEventListener('click', function () {
var checkoutId = parseInt(btn.dataset.checkoutId);
var productName = btn.dataset.productName || 'Loaner';
// Set modal values
document.getElementById('return_modal_checkout_id').value = checkoutId;
document.getElementById('return_modal_product_name').textContent = productName;
document.getElementById('return_modal_condition').value = 'good';
document.getElementById('return_modal_notes').value = '';
// Load locations
var locSelect = document.getElementById('return_modal_location_id');
locSelect.innerHTML = '<option value="">-- Loading... --</option>';
self._rpc('/my/loaner/locations', {}).then(function (locations) {
locations = locations || [];
locSelect.innerHTML = '<option value="">-- Select Location --</option>';
locations.forEach(function (l) {
var opt = document.createElement('option');
opt.value = l.id;
opt.text = l.name;
locSelect.appendChild(opt);
});
});
// Show modal
self._showModal(returnModal);
});
});
// Submit return
if (btnSubmitReturn) {
btnSubmitReturn.addEventListener('click', function () {
var checkoutId = parseInt(document.getElementById('return_modal_checkout_id').value);
var condition = document.getElementById('return_modal_condition').value;
var notes = document.getElementById('return_modal_notes').value;
var locationId = document.getElementById('return_modal_location_id').value;
btnSubmitReturn.disabled = true;
btnSubmitReturn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Processing...';
self._rpc('/my/loaner/return', {
checkout_id: checkoutId,
return_condition: condition,
return_notes: notes,
return_location_id: locationId ? parseInt(locationId) : null,
}).then(function (result) {
if (result.success) {
self._hideModal(returnModal);
alert(result.message);
location.reload();
} else {
alert('Error: ' + (result.error || 'Unknown'));
btnSubmitReturn.disabled = false;
btnSubmitReturn.innerHTML = '<i class="fa fa-check me-1"></i> Confirm Return';
}
});
});
}
},
// =====================================================================
// EXPRESS ASSESSMENT: Loaner Section
// =====================================================================
_initLoanerSection: function () {
var self = this;
var loanerSection = document.getElementById('loanerSection');
if (!loanerSection) return;
var productSelect = document.getElementById('loaner_product_id');
var lotSelect = document.getElementById('loaner_lot_id');
var periodInput = document.getElementById('loaner_period_days');
var checkoutFlag = document.getElementById('loaner_checkout');
var existingFields = document.getElementById('loaner_existing_fields');
var newFields = document.getElementById('loaner_new_fields');
var modeRadios = document.querySelectorAll('input[name="loaner_mode"]');
var btnCreate = document.getElementById('btn_create_loaner_product');
var createResult = document.getElementById('loaner_create_result');
var productsData = [];
loanerSection.addEventListener('show.bs.collapse', function () {
if (productSelect && productSelect.options.length <= 1) {
self._rpc('/my/loaner/products', {}).then(function (data) {
productsData = data || [];
productSelect.innerHTML = '<option value="">-- Select Product --</option>';
productsData.forEach(function (p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.text = p.name + ' (' + p.lots.length + ' avail)';
productSelect.appendChild(opt);
});
});
}
});
loanerSection.addEventListener('shown.bs.collapse', function () {
if (checkoutFlag) checkoutFlag.value = '1';
});
loanerSection.addEventListener('hidden.bs.collapse', function () {
if (checkoutFlag) checkoutFlag.value = '0';
});
modeRadios.forEach(function (radio) {
radio.addEventListener('change', function () {
if (this.value === 'existing') {
if (existingFields) existingFields.style.display = '';
if (newFields) newFields.style.display = 'none';
} else {
if (existingFields) existingFields.style.display = 'none';
if (newFields) newFields.style.display = '';
}
});
});
if (productSelect) {
productSelect.addEventListener('change', function () {
lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
var product = productsData.find(function (p) { return p.id === parseInt(productSelect.value); });
if (product) {
product.lots.forEach(function (lot) {
var opt = document.createElement('option');
opt.value = lot.id;
opt.text = lot.name;
lotSelect.appendChild(opt);
});
if (periodInput && product.period_days) periodInput.value = product.period_days;
}
});
}
if (btnCreate) {
btnCreate.addEventListener('click', function () {
var name = document.getElementById('loaner_new_product_name').value.trim();
var serial = document.getElementById('loaner_new_serial').value.trim();
if (!name || !serial) { alert('Enter both name and serial.'); return; }
btnCreate.disabled = true;
btnCreate.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Creating...';
self._rpc('/my/loaner/create-product', {
product_name: name, serial_number: serial,
}).then(function (result) {
if (result.success) {
var opt = document.createElement('option');
opt.value = result.product_id;
opt.text = result.product_name;
opt.selected = true;
productSelect.appendChild(opt);
lotSelect.innerHTML = '';
var lotOpt = document.createElement('option');
lotOpt.value = result.lot_id;
lotOpt.text = result.lot_name;
lotOpt.selected = true;
lotSelect.appendChild(lotOpt);
document.getElementById('loaner_existing').checked = true;
if (existingFields) existingFields.style.display = '';
if (newFields) newFields.style.display = 'none';
if (createResult) {
createResult.style.display = '';
createResult.innerHTML = '<div class="alert alert-success py-2">Created "' + result.product_name + '" (S/N: ' + result.lot_name + ')</div>';
}
} else {
if (createResult) {
createResult.style.display = '';
createResult.innerHTML = '<div class="alert alert-danger py-2">' + (result.error || 'Error') + '</div>';
}
}
btnCreate.disabled = false;
btnCreate.innerHTML = '<i class="fa fa-plus me-1"></i> Create Product';
});
});
}
},
// =====================================================================
// HELPERS
// =====================================================================
_rpc: function (url, params) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', id: 1, params: params }),
}).then(function (r) { return r.json(); }).then(function (d) { return d.result; });
},
_showModal: function (modalEl) {
if (!modalEl) return;
var Modal = window.bootstrap ? window.bootstrap.Modal : null;
if (Modal) {
var inst = Modal.getOrCreateInstance ? Modal.getOrCreateInstance(modalEl) : new Modal(modalEl);
inst.show();
} else if (window.$ || window.jQuery) {
(window.$ || window.jQuery)(modalEl).modal('show');
}
},
_hideModal: function (modalEl) {
if (!modalEl) return;
try {
var Modal = window.bootstrap ? window.bootstrap.Modal : null;
if (Modal && Modal.getInstance) {
var inst = Modal.getInstance(modalEl);
if (inst) inst.hide();
} else if (window.$ || window.jQuery) {
(window.$ || window.jQuery)(modalEl).modal('hide');
}
} catch (e) { /* non-blocking */ }
},
});

View File

@@ -0,0 +1,626 @@
/**
* Fusion PDF Field Position Editor
*
* Features:
* - Drag field types from sidebar palette onto PDF to create new fields
* - Drag existing fields to reposition them
* - Resize handles on each field (bottom-right corner)
* - Click to select and edit properties in right panel
* - Percentage-based positions (0.0-1.0), same as Odoo Sign module
* - Auto-save on every drag/resize
*/
document.addEventListener('DOMContentLoaded', function () {
'use strict';
var editor = document.getElementById('pdf_field_editor');
if (!editor) return;
var templateId = parseInt(editor.dataset.templateId);
var pageCount = parseInt(editor.dataset.pageCount) || 1;
var currentPage = 1;
var fields = {};
var selectedFieldId = null;
var fieldCounter = 0;
var container = document.getElementById('pdf_canvas_container');
var pageImage = document.getElementById('pdf_page_image');
// ================================================================
// Colors per field type
// ================================================================
// ================================================================
// Available data keys (grouped for the dropdown)
// ================================================================
var DATA_KEYS = [
{ group: 'Client Info', keys: [
{ key: 'client_last_name', label: 'Last Name' },
{ key: 'client_first_name', label: 'First Name' },
{ key: 'client_middle_name', label: 'Middle Name' },
{ key: 'client_name', label: 'Full Name' },
{ key: 'client_health_card', label: 'Health Card Number' },
{ key: 'client_health_card_version', label: 'Health Card Version' },
{ key: 'client_street', label: 'Street' },
{ key: 'client_unit', label: 'Unit/Apt' },
{ key: 'client_city', label: 'City' },
{ key: 'client_state', label: 'Province' },
{ key: 'client_postal_code', label: 'Postal Code' },
{ key: 'client_phone', label: 'Phone' },
{ key: 'client_email', label: 'Email' },
{ key: 'client_weight', label: 'Weight (lbs)' },
]},
{ group: 'Client Type', keys: [
{ key: 'client_type_reg', label: 'REG Checkbox' },
{ key: 'client_type_ods', label: 'ODS Checkbox' },
{ key: 'client_type_acs', label: 'ACS Checkbox' },
{ key: 'client_type_owp', label: 'OWP Checkbox' },
]},
{ group: 'Consent', keys: [
{ key: 'consent_applicant', label: 'Applicant Checkbox' },
{ key: 'consent_agent', label: 'Agent Checkbox' },
{ key: 'consent_date', label: 'Consent Date' },
]},
{ group: 'Agent Relationship', keys: [
{ key: 'agent_rel_spouse', label: 'Spouse Checkbox' },
{ key: 'agent_rel_parent', label: 'Parent Checkbox' },
{ key: 'agent_rel_child', label: 'Child Checkbox' },
{ key: 'agent_rel_poa', label: 'POA Checkbox' },
{ key: 'agent_rel_guardian', label: 'Guardian Checkbox' },
]},
{ group: 'Agent Info', keys: [
{ key: 'agent_last_name', label: 'Agent Last Name' },
{ key: 'agent_first_name', label: 'Agent First Name' },
{ key: 'agent_middle_initial', label: 'Agent Middle Initial' },
{ key: 'agent_unit', label: 'Agent Unit' },
{ key: 'agent_street_number', label: 'Agent Street No.' },
{ key: 'agent_street_name', label: 'Agent Street Name' },
{ key: 'agent_city', label: 'Agent City' },
{ key: 'agent_province', label: 'Agent Province' },
{ key: 'agent_postal_code', label: 'Agent Postal Code' },
{ key: 'agent_home_phone', label: 'Agent Home Phone' },
{ key: 'agent_business_phone', label: 'Agent Business Phone' },
{ key: 'agent_phone_ext', label: 'Agent Phone Ext' },
]},
{ group: 'Equipment', keys: [
{ key: 'equipment_type', label: 'Equipment Type' },
{ key: 'seat_width', label: 'Seat Width' },
{ key: 'seat_depth', label: 'Seat Depth' },
{ key: 'seat_to_floor_height', label: 'Seat to Floor Height' },
{ key: 'back_height', label: 'Back Height' },
{ key: 'legrest_length', label: 'Legrest Length' },
{ key: 'cane_height', label: 'Cane Height' },
]},
{ group: 'Dates', keys: [
{ key: 'assessment_start_date', label: 'Assessment Start Date' },
{ key: 'assessment_end_date', label: 'Assessment End Date' },
{ key: 'claim_authorization_date', label: 'Authorization Date' },
]},
{ group: 'Authorizer', keys: [
{ key: 'authorizer_name', label: 'Authorizer Name' },
{ key: 'authorizer_phone', label: 'Authorizer Phone' },
{ key: 'authorizer_email', label: 'Authorizer Email' },
]},
{ group: 'Signatures', keys: [
{ key: 'signature_page_11', label: 'Page 11 Signature' },
{ key: 'signature_page_12', label: 'Page 12 Signature' },
]},
{ group: 'Other', keys: [
{ key: 'reference', label: 'Assessment Reference' },
{ key: 'reason_for_application', label: 'Reason for Application' },
]},
];
// Build a flat lookup: key -> label
var KEY_LABELS = {};
DATA_KEYS.forEach(function (g) {
g.keys.forEach(function (k) { KEY_LABELS[k.key] = k.label; });
});
// Build <option> HTML for the data key dropdown
function buildDataKeyOptions(selectedKey) {
var html = '<option value="">(custom / none)</option>';
DATA_KEYS.forEach(function (g) {
html += '<optgroup label="' + g.group + '">';
g.keys.forEach(function (k) {
html += '<option value="' + k.key + '"'
+ (k.key === selectedKey ? ' selected' : '')
+ '>' + k.label + ' (' + k.key + ')</option>';
});
html += '</optgroup>';
});
return html;
}
var COLORS = {
text: { bg: 'rgba(52,152,219,0.25)', border: '#3498db' },
checkbox: { bg: 'rgba(46,204,113,0.25)', border: '#2ecc71' },
date: { bg: 'rgba(230,126,34,0.25)', border: '#e67e22' },
signature: { bg: 'rgba(155,89,182,0.25)', border: '#9b59b6' },
};
var DEFAULT_SIZES = {
text: { w: 0.150, h: 0.018 },
checkbox: { w: 0.018, h: 0.018 },
date: { w: 0.120, h: 0.018 },
signature: { w: 0.200, h: 0.050 },
};
// ================================================================
// JSONRPC helper
// ================================================================
function jsonrpc(url, params) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', id: 1, params: params || {} })
}).then(function (r) { return r.json(); })
.then(function (d) {
if (d.error) { console.error('RPC error', d.error); return null; }
return d.result;
});
}
// ================================================================
// Init
// ================================================================
function init() {
loadFields();
setupPageNavigation();
setupPaletteDrag();
setupContainerDrop();
setupPreviewButton();
// Prevent the image from intercepting drag events
if (pageImage) {
pageImage.style.pointerEvents = 'none';
}
// Also prevent any child (except field markers) from blocking drops
container.querySelectorAll('#no_preview_placeholder').forEach(function (el) {
el.style.pointerEvents = 'none';
});
}
// ================================================================
// Load fields
// ================================================================
function loadFields() {
jsonrpc('/fusion/pdf-editor/fields', { template_id: templateId }).then(function (result) {
if (!result) return;
fields = {};
result.forEach(function (f) { fields[f.id] = f; fieldCounter++; });
renderFieldsForPage(currentPage);
});
}
// ================================================================
// Render fields on current page
// ================================================================
function renderFieldsForPage(page) {
container.querySelectorAll('.pdf-field-marker').forEach(function (el) { el.remove(); });
Object.values(fields).forEach(function (f) {
if (f.page === page) renderFieldMarker(f);
});
updateFieldCount();
}
function renderFieldMarker(field) {
var c = COLORS[field.field_type] || COLORS.text;
var marker = document.createElement('div');
marker.className = 'pdf-field-marker';
marker.dataset.fieldId = field.id;
marker.setAttribute('draggable', 'true');
Object.assign(marker.style, {
position: 'absolute',
left: (field.pos_x * 100) + '%',
top: (field.pos_y * 100) + '%',
width: (field.width * 100) + '%',
height: Math.max(field.height * 100, 1.5) + '%',
backgroundColor: c.bg,
border: '2px solid ' + c.border,
borderRadius: '3px',
cursor: 'move',
display: 'flex',
alignItems: 'center',
fontSize: '10px',
color: '#333',
fontWeight: '600',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
padding: '0 4px',
zIndex: 10,
boxSizing: 'border-box',
userSelect: 'none',
});
// Label text
var label = document.createElement('span');
label.style.pointerEvents = 'none';
label.style.flex = '1';
label.style.overflow = 'hidden';
label.style.textOverflow = 'ellipsis';
label.textContent = field.label || field.name;
marker.appendChild(label);
// Resize handle (bottom-right corner)
var handle = document.createElement('div');
Object.assign(handle.style, {
position: 'absolute',
right: '0',
bottom: '0',
width: '10px',
height: '10px',
backgroundColor: c.border,
cursor: 'nwse-resize',
borderRadius: '2px 0 2px 0',
opacity: '0.7',
});
handle.className = 'resize-handle';
handle.addEventListener('mousedown', function (e) {
e.preventDefault();
e.stopPropagation();
startResize(field.id, e);
});
marker.appendChild(handle);
// Tooltip
marker.title = (field.label || field.name) + '\nKey: ' + (field.field_key || 'unmapped') + '\nType: ' + field.field_type;
// Drag to reposition
marker.addEventListener('dragstart', function (e) { onFieldDragStart(e, field.id); });
marker.addEventListener('dragend', function (e) { e.target.style.opacity = ''; });
// Click to select
marker.addEventListener('click', function (e) {
e.stopPropagation();
selectField(field.id);
});
container.appendChild(marker);
// Highlight if selected
if (field.id === selectedFieldId) {
marker.style.boxShadow = '0 0 0 3px #007bff';
marker.style.zIndex = '20';
}
}
// ================================================================
// Drag existing fields to reposition
// ================================================================
var dragOffsetX = 0, dragOffsetY = 0;
var dragFieldId = null;
var dragSource = null; // 'field' or 'palette'
var dragFieldType = null;
function onFieldDragStart(e, fieldId) {
dragSource = 'field';
dragFieldId = fieldId;
var rect = e.target.getBoundingClientRect();
dragOffsetX = e.clientX - rect.left;
dragOffsetY = e.clientY - rect.top;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', 'field');
requestAnimationFrame(function () { e.target.style.opacity = '0.4'; });
}
// ================================================================
// Drag from palette to create new field
// ================================================================
function setupPaletteDrag() {
document.querySelectorAll('.pdf-palette-item').forEach(function (item) {
item.addEventListener('dragstart', function (e) {
dragSource = 'palette';
dragFieldType = e.currentTarget.dataset.fieldType;
dragFieldId = null;
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', 'palette');
e.currentTarget.style.opacity = '0.5';
});
item.addEventListener('dragend', function (e) {
e.currentTarget.style.opacity = '';
});
});
}
// ================================================================
// Drop handler on PDF container
// ================================================================
function setupContainerDrop() {
// Must preventDefault on dragover for drop to fire
container.addEventListener('dragover', function (e) {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = (dragSource === 'palette') ? 'copy' : 'move';
});
container.addEventListener('dragenter', function (e) {
e.preventDefault();
e.stopPropagation();
});
container.addEventListener('drop', function (e) {
e.preventDefault();
e.stopPropagation();
// Use the container rect as the reference area
// (the image has pointer-events:none, so we use the container which matches its size)
var rect = container.getBoundingClientRect();
if (dragSource === 'palette' && dragFieldType) {
// ---- CREATE new field at drop position ----
var defaults = DEFAULT_SIZES[dragFieldType] || DEFAULT_SIZES.text;
var posX = (e.clientX - rect.left) / rect.width;
var posY = (e.clientY - rect.top) / rect.height;
posX = normalize(posX, defaults.w);
posY = normalize(posY, defaults.h);
posX = round3(posX);
posY = round3(posY);
fieldCounter++;
var autoName = dragFieldType + '_' + fieldCounter;
var newField = {
template_id: templateId,
name: autoName,
label: autoName,
field_type: dragFieldType,
field_key: autoName,
page: currentPage,
pos_x: posX,
pos_y: posY,
width: defaults.w,
height: defaults.h,
font_size: 10,
};
jsonrpc('/fusion/pdf-editor/create-field', newField).then(function (res) {
if (res && res.id) {
newField.id = res.id;
fields[res.id] = newField;
renderFieldsForPage(currentPage);
selectField(res.id);
}
});
} else if (dragSource === 'field' && dragFieldId && fields[dragFieldId]) {
// ---- MOVE existing field ----
var field = fields[dragFieldId];
var posX = (e.clientX - rect.left - dragOffsetX) / rect.width;
var posY = (e.clientY - rect.top - dragOffsetY) / rect.height;
posX = normalize(posX, field.width);
posY = normalize(posY, field.height);
posX = round3(posX);
posY = round3(posY);
field.pos_x = posX;
field.pos_y = posY;
saveField(field.id, { pos_x: posX, pos_y: posY });
renderFieldsForPage(currentPage);
selectField(field.id);
}
dragSource = null;
dragFieldId = null;
dragFieldType = null;
});
}
// ================================================================
// Resize handles
// ================================================================
function startResize(fieldId, startEvent) {
var field = fields[fieldId];
if (!field) return;
var imgRect = container.getBoundingClientRect();
var startX = startEvent.clientX;
var startY = startEvent.clientY;
var startW = field.width;
var startH = field.height;
var marker = container.querySelector('[data-field-id="' + fieldId + '"]');
function onMove(e) {
var dx = (e.clientX - startX) / imgRect.width;
var dy = (e.clientY - startY) / imgRect.height;
var newW = Math.max(startW + dx, 0.010);
var newH = Math.max(startH + dy, 0.005);
// Clamp to page bounds
if (field.pos_x + newW > 1.0) newW = 1.0 - field.pos_x;
if (field.pos_y + newH > 1.0) newH = 1.0 - field.pos_y;
field.width = round3(newW);
field.height = round3(newH);
if (marker) {
marker.style.width = (field.width * 100) + '%';
marker.style.height = Math.max(field.height * 100, 1.5) + '%';
}
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
saveField(fieldId, { width: field.width, height: field.height });
renderFieldsForPage(currentPage);
selectField(fieldId);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
// ================================================================
// Select field and show properties
// ================================================================
function selectField(fieldId) {
selectedFieldId = fieldId;
var field = fields[fieldId];
if (!field) return;
// Re-render to update highlights
renderFieldsForPage(currentPage);
var panel = document.getElementById('field_props_body');
panel.innerHTML = ''
+ '<div class="mb-2">'
+ ' <label class="form-label fw-bold small mb-0">Data Key</label>'
+ ' <select class="form-select form-select-sm" id="prop_field_key">'
+ buildDataKeyOptions(field.field_key || '')
+ ' </select>'
+ '</div>'
+ row('Name', 'text', 'prop_name', field.name || '')
+ row('Label', 'text', 'prop_label', field.label || '')
+ '<div class="mb-2">'
+ ' <label class="form-label fw-bold small mb-0">Type</label>'
+ ' <select class="form-select form-select-sm" id="prop_type">'
+ ' <option value="text"' + sel(field.field_type, 'text') + '>Text</option>'
+ ' <option value="checkbox"' + sel(field.field_type, 'checkbox') + '>Checkbox</option>'
+ ' <option value="signature"' + sel(field.field_type, 'signature') + '>Signature</option>'
+ ' <option value="date"' + sel(field.field_type, 'date') + '>Date</option>'
+ ' </select>'
+ '</div>'
+ '<div class="row mb-2">'
+ ' <div class="col-6">' + row('Font Size', 'number', 'prop_font_size', field.font_size || 10, '0.5') + '</div>'
+ ' <div class="col-6">' + row('Page', 'number', 'prop_page', field.page || 1) + '</div>'
+ '</div>'
+ '<div class="row mb-2">'
+ ' <div class="col-6">' + row('Width', 'number', 'prop_width', field.width || 0.15, '0.005') + '</div>'
+ ' <div class="col-6">' + row('Height', 'number', 'prop_height', field.height || 0.015, '0.005') + '</div>'
+ '</div>'
+ '<div class="row mb-2">'
+ ' <div class="col-6"><label class="form-label fw-bold small mb-0">X</label>'
+ ' <input type="text" class="form-control form-control-sm" value="' + round3(field.pos_x) + '" readonly/></div>'
+ ' <div class="col-6"><label class="form-label fw-bold small mb-0">Y</label>'
+ ' <input type="text" class="form-control form-control-sm" value="' + round3(field.pos_y) + '" readonly/></div>'
+ '</div>'
+ '<button type="button" class="btn btn-primary btn-sm w-100 mb-2" id="btn_save_props">'
+ ' <i class="fa fa-save me-1"/>Save</button>'
+ '<button type="button" class="btn btn-outline-danger btn-sm w-100" id="btn_delete_field">'
+ ' <i class="fa fa-trash me-1"/>Delete</button>';
// Auto-fill name and label when data key is selected
document.getElementById('prop_field_key').addEventListener('change', function () {
var selectedKey = this.value;
if (selectedKey && KEY_LABELS[selectedKey]) {
document.getElementById('prop_name').value = selectedKey;
document.getElementById('prop_label').value = KEY_LABELS[selectedKey];
}
});
document.getElementById('btn_save_props').addEventListener('click', function () {
var keySelect = document.getElementById('prop_field_key');
var selectedKey = keySelect ? keySelect.value : '';
var vals = {
name: val('prop_name'),
label: val('prop_label'),
field_key: selectedKey,
field_type: val('prop_type'),
font_size: parseFloat(val('prop_font_size')) || 10,
page: parseInt(val('prop_page')) || 1,
width: parseFloat(val('prop_width')) || 0.15,
height: parseFloat(val('prop_height')) || 0.015,
};
Object.assign(field, vals);
saveField(fieldId, vals);
renderFieldsForPage(currentPage);
selectField(fieldId);
});
document.getElementById('btn_delete_field').addEventListener('click', function () {
if (!confirm('Delete "' + (field.label || field.name) + '"?')) return;
jsonrpc('/fusion/pdf-editor/delete-field', { field_id: fieldId }).then(function () {
delete fields[fieldId];
selectedFieldId = null;
renderFieldsForPage(currentPage);
panel.innerHTML = '<p class="text-muted small">Field deleted.</p>';
});
});
}
// ================================================================
// Save field to server
// ================================================================
function saveField(fieldId, values) {
jsonrpc('/fusion/pdf-editor/update-field', { field_id: fieldId, values: values });
}
// ================================================================
// Page navigation
// ================================================================
function setupPageNavigation() {
var prev = document.getElementById('btn_prev_page');
var next = document.getElementById('btn_next_page');
if (prev) prev.addEventListener('click', function () { if (currentPage > 1) switchPage(--currentPage); });
if (next) next.addEventListener('click', function () { if (currentPage < pageCount) switchPage(++currentPage); });
}
function switchPage(page) {
currentPage = page;
var d = document.getElementById('current_page_display');
if (d) d.textContent = page;
jsonrpc('/fusion/pdf-editor/page-image', { template_id: templateId, page: page }).then(function (r) {
if (r && r.image_url && pageImage) pageImage.src = r.image_url;
renderFieldsForPage(page);
});
}
// ================================================================
// Preview
// ================================================================
function setupPreviewButton() {
var btn = document.getElementById('btn_preview');
if (btn) btn.addEventListener('click', function () {
window.open('/fusion/pdf-editor/preview/' + templateId, '_blank');
});
}
// ================================================================
// Helpers
// ================================================================
function normalize(pos, dim) {
if (pos < 0) return 0;
if (pos + dim > 1.0) return 1.0 - dim;
return pos;
}
function round3(n) { return Math.round((n || 0) * 1000) / 1000; }
function val(id) { var el = document.getElementById(id); return el ? el.value : ''; }
function sel(current, option) { return current === option ? ' selected' : ''; }
function row(label, type, id, value, step) {
return '<div class="mb-2"><label class="form-label fw-bold small mb-0">' + label + '</label>'
+ '<input type="' + type + '" class="form-control form-control-sm" id="' + id + '"'
+ ' value="' + value + '"' + (step ? ' step="' + step + '"' : '') + '/></div>';
}
function updateFieldCount() {
var el = document.getElementById('field_count');
if (el) el.textContent = Object.keys(fields).length;
}
// ================================================================
// Start
// ================================================================
init();
});

View File

@@ -0,0 +1,161 @@
/**
* Fusion Authorizer Portal - Real-time Search
*/
odoo.define('fusion_authorizer_portal.portal_search', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
var ajax = require('web.ajax');
publicWidget.registry.PortalSearch = publicWidget.Widget.extend({
selector: '#portal-search-input',
events: {
'input': '_onSearchInput',
'keydown': '_onKeyDown',
},
init: function () {
this._super.apply(this, arguments);
this.debounceTimer = null;
this.searchEndpoint = this._getSearchEndpoint();
this.resultsContainer = null;
},
start: function () {
this._super.apply(this, arguments);
this.resultsContainer = document.getElementById('cases-table-body');
return Promise.resolve();
},
_getSearchEndpoint: function () {
// Determine which portal we're on
var path = window.location.pathname;
if (path.includes('/my/authorizer')) {
return '/my/authorizer/cases/search';
} else if (path.includes('/my/sales')) {
return '/my/sales/cases/search';
}
return null;
},
_onSearchInput: function (ev) {
var self = this;
var query = ev.target.value.trim();
clearTimeout(this.debounceTimer);
if (query.length < 2) {
// If query is too short, reload original page
return;
}
// Debounce - wait 250ms before searching
this.debounceTimer = setTimeout(function () {
self._performSearch(query);
}, 250);
},
_onKeyDown: function (ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
var query = ev.target.value.trim();
if (query.length >= 2) {
this._performSearch(query);
}
} else if (ev.key === 'Escape') {
ev.target.value = '';
window.location.reload();
}
},
_performSearch: function (query) {
var self = this;
if (!this.searchEndpoint) {
console.error('Search endpoint not found');
return;
}
// Show loading indicator
this._showLoading(true);
ajax.jsonRpc(this.searchEndpoint, 'call', {
query: query
}).then(function (response) {
self._showLoading(false);
if (response.error) {
console.error('Search error:', response.error);
return;
}
self._renderResults(response.results, query);
}).catch(function (error) {
self._showLoading(false);
console.error('Search failed:', error);
});
},
_showLoading: function (show) {
var spinner = document.querySelector('.search-loading');
if (spinner) {
spinner.classList.toggle('active', show);
}
},
_renderResults: function (results, query) {
if (!this.resultsContainer) {
return;
}
if (!results || results.length === 0) {
this.resultsContainer.innerHTML = `
<tr>
<td colspan="6" class="text-center py-4">
<i class="fa fa-search fa-2x text-muted mb-2"></i>
<p class="text-muted mb-0">No results found for "${query}"</p>
</td>
</tr>
`;
return;
}
var html = '';
var isAuthorizer = window.location.pathname.includes('/my/authorizer');
var baseUrl = isAuthorizer ? '/my/authorizer/case/' : '/my/sales/case/';
results.forEach(function (order) {
var stateClass = 'bg-secondary';
if (order.state === 'sent') stateClass = 'bg-primary';
else if (order.state === 'sale') stateClass = 'bg-success';
html += `
<tr class="search-result-row">
<td>${self._highlightMatch(order.name, query)}</td>
<td>${self._highlightMatch(order.partner_name, query)}</td>
<td>${order.date_order}</td>
<td>${self._highlightMatch(order.claim_number || '-', query)}</td>
<td><span class="badge ${stateClass}">${order.state_display}</span></td>
<td>
<a href="${baseUrl}${order.id}" class="btn btn-sm btn-primary">
<i class="fa fa-eye me-1"></i>View
</a>
</td>
</tr>
`;
});
this.resultsContainer.innerHTML = html;
},
_highlightMatch: function (text, query) {
if (!text || !query) return text || '';
var regex = new RegExp('(' + query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
return text.replace(regex, '<span class="search-highlight">$1</span>');
}
});
return publicWidget.registry.PortalSearch;
});

View File

@@ -0,0 +1,167 @@
/**
* Fusion Authorizer Portal - Signature Pad
* Touch-enabled digital signature capture
*/
odoo.define('fusion_authorizer_portal.signature_pad', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
var ajax = require('web.ajax');
// Signature Pad Class
var SignaturePad = function (canvas, options) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.options = Object.assign({
strokeColor: '#000000',
strokeWidth: 2,
backgroundColor: '#ffffff'
}, options || {});
this.isDrawing = false;
this.lastX = 0;
this.lastY = 0;
this.points = [];
this._initialize();
};
SignaturePad.prototype = {
_initialize: function () {
var self = this;
// Set canvas size
this._resizeCanvas();
// Set drawing style
this.ctx.strokeStyle = this.options.strokeColor;
this.ctx.lineWidth = this.options.strokeWidth;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
// Clear with background color
this.clear();
// Event listeners
this.canvas.addEventListener('mousedown', this._startDrawing.bind(this));
this.canvas.addEventListener('mousemove', this._draw.bind(this));
this.canvas.addEventListener('mouseup', this._stopDrawing.bind(this));
this.canvas.addEventListener('mouseout', this._stopDrawing.bind(this));
// Touch events
this.canvas.addEventListener('touchstart', this._startDrawing.bind(this), { passive: false });
this.canvas.addEventListener('touchmove', this._draw.bind(this), { passive: false });
this.canvas.addEventListener('touchend', this._stopDrawing.bind(this), { passive: false });
this.canvas.addEventListener('touchcancel', this._stopDrawing.bind(this), { passive: false });
// Resize handler
window.addEventListener('resize', this._resizeCanvas.bind(this));
},
_resizeCanvas: function () {
var rect = this.canvas.getBoundingClientRect();
var ratio = window.devicePixelRatio || 1;
this.canvas.width = rect.width * ratio;
this.canvas.height = rect.height * ratio;
this.ctx.scale(ratio, ratio);
this.canvas.style.width = rect.width + 'px';
this.canvas.style.height = rect.height + 'px';
// Restore drawing style after resize
this.ctx.strokeStyle = this.options.strokeColor;
this.ctx.lineWidth = this.options.strokeWidth;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
},
_getPos: function (e) {
var rect = this.canvas.getBoundingClientRect();
var x, y;
if (e.touches && e.touches.length > 0) {
x = e.touches[0].clientX - rect.left;
y = e.touches[0].clientY - rect.top;
} else {
x = e.clientX - rect.left;
y = e.clientY - rect.top;
}
return { x: x, y: y };
},
_startDrawing: function (e) {
e.preventDefault();
this.isDrawing = true;
var pos = this._getPos(e);
this.lastX = pos.x;
this.lastY = pos.y;
this.points.push({ x: pos.x, y: pos.y, start: true });
},
_draw: function (e) {
e.preventDefault();
if (!this.isDrawing) return;
var pos = this._getPos(e);
this.ctx.beginPath();
this.ctx.moveTo(this.lastX, this.lastY);
this.ctx.lineTo(pos.x, pos.y);
this.ctx.stroke();
this.lastX = pos.x;
this.lastY = pos.y;
this.points.push({ x: pos.x, y: pos.y });
},
_stopDrawing: function (e) {
e.preventDefault();
this.isDrawing = false;
},
clear: function () {
this.ctx.fillStyle = this.options.backgroundColor;
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.points = [];
},
isEmpty: function () {
return this.points.length === 0;
},
toDataURL: function (type, quality) {
return this.canvas.toDataURL(type || 'image/png', quality || 1.0);
}
};
// Make SignaturePad available globally for inline scripts
window.SignaturePad = SignaturePad;
// Widget for signature pads in portal
publicWidget.registry.SignaturePadWidget = publicWidget.Widget.extend({
selector: '.signature-pad-container',
start: function () {
this._super.apply(this, arguments);
var canvas = this.el.querySelector('canvas');
if (canvas) {
this.signaturePad = new SignaturePad(canvas);
}
return Promise.resolve();
},
getSignaturePad: function () {
return this.signaturePad;
}
});
return {
SignaturePad: SignaturePad,
Widget: publicWidget.registry.SignaturePadWidget
};
});

View File

@@ -0,0 +1,97 @@
/**
* Technician Location Logger
* Logs GPS location every 5 minutes during working hours (9 AM - 6 PM)
* Only logs while the browser tab is visible.
*/
(function () {
'use strict';
var INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
var STORE_OPEN_HOUR = 9;
var STORE_CLOSE_HOUR = 18;
var locationTimer = null;
function isWorkingHours() {
var now = new Date();
var hour = now.getHours();
return hour >= STORE_OPEN_HOUR && hour < STORE_CLOSE_HOUR;
}
function isTechnicianPortal() {
// Check if we're on a technician portal page
return window.location.pathname.indexOf('/my/technician') !== -1;
}
function logLocation() {
if (!isWorkingHours()) {
return;
}
if (document.hidden) {
return;
}
if (!navigator.geolocation) {
return;
}
navigator.geolocation.getCurrentPosition(
function (position) {
var data = {
jsonrpc: '2.0',
method: 'call',
params: {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy || 0,
}
};
fetch('/my/technician/location/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).catch(function () {
// Silently fail - location logging is best-effort
});
},
function () {
// Geolocation permission denied or error - silently ignore
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
);
}
function startLocationLogging() {
if (!isTechnicianPortal()) {
return;
}
// Log immediately on page load
logLocation();
// Set interval for periodic logging
locationTimer = setInterval(logLocation, INTERVAL_MS);
// Pause/resume on tab visibility change
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
// Tab hidden - clear interval to save battery
if (locationTimer) {
clearInterval(locationTimer);
locationTimer = null;
}
} else {
// Tab visible again - log immediately and restart interval
logLocation();
if (!locationTimer) {
locationTimer = setInterval(logLocation, INTERVAL_MS);
}
}
});
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startLocationLogging);
} else {
startLocationLogging();
}
})();

View File

@@ -0,0 +1,96 @@
/**
* Fusion Technician Portal - Push Notification Registration
* Registers service worker and subscribes to push notifications.
* Include this script on technician portal pages.
*/
(function() {
'use strict';
// Only run on technician portal pages
if (!document.querySelector('.tech-portal') && !window.location.pathname.startsWith('/my/technician')) {
return;
}
// Get VAPID public key from meta tag or page data
var vapidMeta = document.querySelector('meta[name="vapid-public-key"]');
var vapidPublicKey = vapidMeta ? vapidMeta.content : null;
if (!vapidPublicKey || !('serviceWorker' in navigator) || !('PushManager' in window)) {
return;
}
function urlBase64ToUint8Array(base64String) {
var padding = '='.repeat((4 - base64String.length % 4) % 4);
var base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
var rawData = window.atob(base64);
var outputArray = new Uint8Array(rawData.length);
for (var i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
async function registerPushSubscription() {
try {
// Register service worker
var registration = await navigator.serviceWorker.register(
'/fusion_authorizer_portal/static/src/js/technician_sw.js',
{scope: '/my/technician/'}
);
// Wait for service worker to be ready
await navigator.serviceWorker.ready;
// Check existing subscription
var subscription = await registration.pushManager.getSubscription();
if (!subscription) {
// Request permission
var permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.log('[TechPush] Notification permission denied');
return;
}
// Subscribe
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
}
// Send subscription to server
var key = subscription.getKey('p256dh');
var auth = subscription.getKey('auth');
var response = await fetch('/my/technician/push/subscribe', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
endpoint: subscription.endpoint,
p256dh: btoa(String.fromCharCode.apply(null, new Uint8Array(key))),
auth: btoa(String.fromCharCode.apply(null, new Uint8Array(auth))),
}
}),
});
var data = await response.json();
if (data.result && data.result.success) {
console.log('[TechPush] Push subscription registered successfully');
}
} catch (error) {
console.warn('[TechPush] Push registration failed:', error);
}
}
// Register after page load
if (document.readyState === 'complete') {
registerPushSubscription();
} else {
window.addEventListener('load', registerPushSubscription);
}
})();

View File

@@ -0,0 +1,77 @@
/**
* Fusion Technician Portal - Service Worker for Push Notifications
* Handles push events and notification clicks.
*/
self.addEventListener('push', function(event) {
if (!event.data) return;
var data;
try {
data = event.data.json();
} catch (e) {
data = {title: 'New Notification', body: event.data.text()};
}
var options = {
body: data.body || '',
icon: '/fusion_authorizer_portal/static/description/icon.png',
badge: '/fusion_authorizer_portal/static/description/icon.png',
tag: 'tech-task-' + (data.task_id || 'general'),
renotify: true,
data: {
url: data.url || '/my/technician',
taskId: data.task_id,
taskType: data.task_type,
},
actions: [],
};
// Add contextual actions based on task type
if (data.url) {
options.actions.push({action: 'view', title: 'View Task'});
}
if (data.task_type === 'delivery' || data.task_type === 'repair') {
options.actions.push({action: 'navigate', title: 'Navigate'});
}
event.waitUntil(
self.registration.showNotification(data.title || 'Fusion Technician', options)
);
});
self.addEventListener('notificationclick', function(event) {
event.notification.close();
var url = '/my/technician';
if (event.notification.data && event.notification.data.url) {
url = event.notification.data.url;
}
if (event.action === 'navigate' && event.notification.data && event.notification.data.taskId) {
// Open Google Maps for the task (will redirect through portal)
url = '/my/technician/task/' + event.notification.data.taskId;
}
event.waitUntil(
clients.matchAll({type: 'window', includeUncontrolled: true}).then(function(clientList) {
// Focus existing window if open
for (var i = 0; i < clientList.length; i++) {
var client = clientList[i];
if (client.url.indexOf('/my/technician') !== -1 && 'focus' in client) {
client.navigate(url);
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(url);
}
})
);
});
// Keep service worker alive
self.addEventListener('activate', function(event) {
event.waitUntil(self.clients.claim());
});