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,192 @@
/** @odoo-module **/
/**
* Image compression for file uploads on Odoo.
*
* Problem: On iPhone, selecting 4+ photos (5-15MB each) causes the
* browser tab to crash because Odoo converts each to a base64 data URL
* before uploading. 7 photos = 50-100MB of strings in memory.
*
* Solution: Intercept at the FileUploader level, compress each image
* via Canvas BEFORE the data URL conversion. A 5MB photo becomes ~300KB.
*
* The FileUploader.onFileChange is completely overridden (not wrapped)
* to avoid any DataTransfer API issues on iPhone Safari.
*/
import { AttachmentUploadService } from "@mail/core/common/attachment_upload_service";
import { FileUploader } from "@web/views/fields/file_handler";
import { patch } from "@web/core/utils/patch";
import { getDataURLFromFile } from "@web/core/utils/urls";
import { checkFileSize } from "@web/core/utils/files";
const IMAGE_TYPES = new Set([
"image/jpeg", "image/png", "image/webp", "image/bmp",
"image/heic", "image/heif",
]);
const MAX_DIMENSION = 1280; // Conservative for mobile memory
const JPEG_QUALITY = 0.80;
const SKIP_THRESHOLD = 500 * 1024; // 500KB
/**
* Compress an image File via Canvas API.
* Returns the original file if anything fails.
*/
function compressImageFile(file) {
return new Promise((resolve) => {
try {
const img = new Image();
const objectUrl = URL.createObjectURL(file);
const cleanup = () => {
try { URL.revokeObjectURL(objectUrl); } catch(e) {}
try { img.src = ""; } catch(e) {}
};
const timeout = setTimeout(() => {
cleanup();
resolve(file); // Timeout fallback after 10s
}, 10000);
img.onload = () => {
try {
clearTimeout(timeout);
let w = img.naturalWidth || img.width;
let h = img.naturalHeight || img.height;
if (w > MAX_DIMENSION || h > MAX_DIMENSION) {
const ratio = Math.min(MAX_DIMENSION / w, MAX_DIMENSION / h);
w = Math.round(w * ratio);
h = Math.round(h * ratio);
}
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
canvas.getContext("2d").drawImage(img, 0, 0, w, h);
canvas.toBlob(
(blob) => {
cleanup();
canvas.width = 0;
canvas.height = 0;
if (!blob) { resolve(file); return; }
const name = file.name.replace(/\.[^.]+$/, "") + ".jpg";
resolve(new File([blob], name, {
type: "image/jpeg",
lastModified: file.lastModified,
}));
},
"image/jpeg",
JPEG_QUALITY
);
} catch (e) {
clearTimeout(timeout);
cleanup();
resolve(file);
}
};
img.onerror = () => {
clearTimeout(timeout);
cleanup();
resolve(file);
};
img.src = objectUrl;
} catch (e) {
resolve(file);
}
});
}
/**
* Override FileUploader.onFileChange to compress images before
* converting to data URLs. This prevents the massive memory spike
* that crashes mobile browsers.
*
* We re-implement onFileChange instead of wrapping it to avoid
* DataTransfer API issues on iPhone Safari.
*/
patch(FileUploader.prototype, {
async onFileChange(ev) {
const rawFiles = ev.target?.files;
if (!rawFiles || !rawFiles.length) {
return;
}
// Check if any file needs compression
let hasLargeImages = false;
for (const f of rawFiles) {
if (IMAGE_TYPES.has(f.type) && f.size > SKIP_THRESHOLD) {
hasLargeImages = true;
break;
}
}
// No large images -- use standard Odoo behavior
if (!hasLargeImages) {
return super.onFileChange(ev);
}
// Process files one at a time with compression
const files = [...rawFiles].filter((f) => this.validFileType(f));
const target = ev.target;
for (const file of files) {
let processedFile = file;
// Compress large images
if (IMAGE_TYPES.has(file.type) && file.size > SKIP_THRESHOLD) {
try {
processedFile = await compressImageFile(file);
} catch (e) {
processedFile = file; // fallback to original
}
}
// Size check
if (this.props.checkSize && !checkFileSize(processedFile.size, this.notification)) {
continue;
}
this.state.isUploading = true;
try {
const data = await getDataURLFromFile(processedFile);
if (!processedFile.size) {
this.notification.add(
`There was a problem while uploading: ${processedFile.name}`,
{ type: "danger" }
);
continue;
}
await this.props.onUploaded({
name: processedFile.name,
size: processedFile.size,
type: processedFile.type,
data: data.split(",")[1],
objectUrl:
processedFile.type === "application/pdf"
? URL.createObjectURL(processedFile)
: null,
});
} catch (e) {
// Skip this file on error, continue with others
} finally {
this.state.isUploading = false;
}
}
// Reset input so same file can be re-selected
target.value = null;
if (this.props.multiUpload && this.props.onUploadComplete) {
this.props.onUploadComplete({});
}
},
});
/**
* Safety net for drag-drop and paste uploads that bypass FileUploader.
*/
patch(AttachmentUploadService.prototype, {
async upload(thread, composer, file, options) {
if (file && IMAGE_TYPES.has(file.type) && file.size > SKIP_THRESHOLD) {
try {
file = await compressImageFile(file);
} catch (e) {
// Use original file
}
}
return super.upload(thread, composer, file, options);
},
});

View File

@@ -0,0 +1,22 @@
/** @odoo-module **/
// Fusion Claims - Calendar Store Hours Restriction
// Copyright 2024-2026 Nexa Systems Inc.
// License OPL-1
//
// Restricts the technician task calendar view to only show store hours.
// Default: 9:00 AM - 6:00 PM (configurable in Settings).
import { patch } from "@web/core/utils/patch";
import { CalendarRenderer } from "@web/views/calendar/calendar_renderer";
patch(CalendarRenderer.prototype, {
get fcOptions() {
const options = super.fcOptions;
// Only restrict hours for the technician task calendar
if (this.props.model?.resModel === "fusion.technician.task") {
options.slotMinTime = "08:00:00";
options.slotMaxTime = "19:00:00";
}
return options;
},
});

View File

@@ -0,0 +1,40 @@
/** @odoo-module **/
// Fusion Claims - Chatter Topbar Tooltips
// Copyright 2024-2026 Nexa Systems Inc.
// License OPL-1
//
// Adds title (tooltip) attributes to chatter topbar buttons that have
// their text hidden via CSS (icon-only mode).
const TOOLTIPS = {
'.o-mail-Chatter-sendMessage': 'Send Message',
'.o-mail-Chatter-logNote': 'Log Note',
'button[data-hotkey="shift+w"]': 'WhatsApp',
'.o-mail-Chatter-activity': 'Schedule Activity',
'.fusion-notes-mic-btn': 'Record Voice Note',
'.o-mail-Chatter-messageAuthorizer': 'Message Authorizer',
};
function applyTooltips() {
for (const [selector, title] of Object.entries(TOOLTIPS)) {
for (const btn of document.querySelectorAll(selector)) {
if (!btn.getAttribute('title')) {
btn.setAttribute('title', title);
}
}
}
}
// Run on DOM changes (OWL re-renders)
const observer = new MutationObserver(() => applyTooltips());
// Start observing once DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
observer.observe(document.body, { childList: true, subtree: true });
applyTooltips();
});
} else {
observer.observe(document.body, { childList: true, subtree: true });
applyTooltips();
}

View File

@@ -0,0 +1,296 @@
/** @odoo-module **/
// Fusion Claims - Document Preview (PDF and XML)
// Copyright 2024-2025 Nexa Systems Inc.
// License OPL-1
import { registry } from "@web/core/registry";
import { _t } from "@web/core/l10n/translation";
import { Component, useState, onMounted } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { useService } from "@web/core/utils/hooks";
/**
* PDF Document Preview Dialog Component
* Uses Odoo's built-in PDF.js viewer for XFA/protected PDF support
*/
export class DocumentPreviewDialog extends Component {
static template = "fusion_claims.DocumentPreviewDialog";
static components = { Dialog };
setup() {
this.state = useState({
isLoading: true,
isMaximized: false
});
}
getViewerUrl() {
const pdfUrl = `/web/content/${this.props.attachmentId}`;
const encodedUrl = encodeURIComponent(pdfUrl);
return `/web/static/lib/pdfjs/web/viewer.html?file=${encodedUrl}#pagemode=none`;
}
onIframeLoad() {
this.state.isLoading = false;
}
toggleMaximize() {
this.state.isMaximized = !this.state.isMaximized;
}
getDialogSize() {
return this.state.isMaximized ? 'fullscreen' : 'xl';
}
getFrameStyle() {
return this.state.isMaximized
? 'height: calc(98vh - 100px); width: 100%;'
: 'height: calc(85vh - 100px); width: 100%;';
}
}
/**
* XML Viewer Dialog Component
* Displays XML content with syntax highlighting
*/
export class XMLViewerDialog extends Component {
static template = "fusion_claims.XMLViewerDialog";
static components = { Dialog };
setup() {
this.state = useState({
isLoading: true,
isMaximized: false,
xmlContent: '',
formattedXml: '',
error: null
});
this.notification = useService("notification");
onMounted(async () => {
await this.loadXmlContent();
});
}
async loadXmlContent() {
try {
const response = await fetch(`/web/content/${this.props.attachmentId}`);
if (!response.ok) {
throw new Error('Failed to load XML file');
}
const xmlText = await response.text();
this.state.xmlContent = xmlText;
this.state.formattedXml = this.formatXml(xmlText);
this.state.isLoading = false;
} catch (error) {
this.state.error = error.message;
this.state.isLoading = false;
}
}
formatXml(xml) {
// Format XML with indentation and syntax highlighting
let formatted = '';
let indent = 0;
const tab = ' ';
// Split by tags
xml = xml.replace(/>\s*</g, '><');
const nodes = xml.split(/(<[^>]+>)/g).filter(n => n.trim());
for (const node of nodes) {
if (node.startsWith('</')) {
// Closing tag - decrease indent
indent = Math.max(0, indent - 1);
formatted += tab.repeat(indent) + this.highlightXml(node) + '\n';
} else if (node.startsWith('<?') || node.startsWith('<!')) {
// Declaration or comment
formatted += this.highlightXml(node) + '\n';
} else if (node.startsWith('<') && node.endsWith('/>')) {
// Self-closing tag
formatted += tab.repeat(indent) + this.highlightXml(node) + '\n';
} else if (node.startsWith('<')) {
// Opening tag
formatted += tab.repeat(indent) + this.highlightXml(node) + '\n';
indent++;
} else {
// Text content
const trimmed = node.trim();
if (trimmed) {
formatted += tab.repeat(indent) + this.escapeHtml(trimmed) + '\n';
}
}
}
return formatted;
}
highlightXml(str) {
// Escape HTML first
str = this.escapeHtml(str);
// Highlight tag names
str = str.replace(/&lt;(\/?)([\w:-]+)/g,
'&lt;$1<span class="xml-tag">$2</span>');
// Highlight attributes
str = str.replace(/([\w:-]+)=(&quot;[^&]*&quot;)/g,
'<span class="xml-attr">$1</span>=<span class="xml-value">$2</span>');
return str;
}
escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
toggleMaximize() {
this.state.isMaximized = !this.state.isMaximized;
}
getDialogSize() {
return this.state.isMaximized ? 'fullscreen' : 'xl';
}
async copyToClipboard() {
try {
await navigator.clipboard.writeText(this.state.xmlContent);
this.notification.add(_t("XML copied to clipboard"), { type: 'success' });
} catch (error) {
this.notification.add(_t("Failed to copy to clipboard"), { type: 'warning' });
}
}
downloadXml() {
window.open(`/web/content/${this.props.attachmentId}?download=true`, '_blank');
}
}
/**
* Client action to preview a PDF document
*/
async function previewDocumentAction(env, action) {
const attachmentId = action.params?.attachment_id;
const title = action.params?.title || "Document Preview";
if (!attachmentId) {
env.services.notification.add(
_t("No document has been uploaded yet."),
{ type: 'warning', title: _t("No Document") }
);
return;
}
env.services.dialog.add(DocumentPreviewDialog, {
attachmentId: attachmentId,
title: title
});
}
/**
* Client action to preview an XML document
*/
async function previewXmlAction(env, action) {
const attachmentId = action.params?.attachment_id;
const title = action.params?.title || "XML Viewer";
if (!attachmentId) {
env.services.notification.add(
_t("No XML file has been uploaded yet."),
{ type: 'warning', title: _t("No Document") }
);
return;
}
env.services.dialog.add(XMLViewerDialog, {
attachmentId: attachmentId,
title: title
});
}
// Register client actions
registry.category("actions").add("fusion_claims.preview_document", previewDocumentAction);
registry.category("actions").add("fusion_claims.preview_xml", previewXmlAction);
/**
* Image Preview Dialog Component
* Full-screen image preview with navigation
*/
export class ImagePreviewDialog extends Component {
static template = "fusion_claims.ImagePreviewDialog";
static components = { Dialog };
setup() {
this.state = useState({
currentIndex: this.props.initialIndex || 0,
isLoading: true
});
}
get currentImage() {
return this.props.images[this.state.currentIndex];
}
get imageUrl() {
return `/web/image/${this.currentImage.id}`;
}
get hasMultiple() {
return this.props.images.length > 1;
}
get currentPosition() {
return `${this.state.currentIndex + 1} / ${this.props.images.length}`;
}
onImageLoad() {
this.state.isLoading = false;
}
previousImage() {
if (this.state.currentIndex > 0) {
this.state.isLoading = true;
this.state.currentIndex--;
}
}
nextImage() {
if (this.state.currentIndex < this.props.images.length - 1) {
this.state.isLoading = true;
this.state.currentIndex++;
}
}
downloadImage() {
window.open(`/web/content/${this.currentImage.id}?download=true`, '_blank');
}
}
/**
* Client action to preview images
*/
async function previewImageAction(env, action) {
const images = action.params?.images || [];
const initialIndex = action.params?.initial_index || 0;
const title = action.params?.title || "Image Preview";
if (!images.length) {
env.services.notification.add(
_t("No images available."),
{ type: 'warning', title: _t("No Images") }
);
return;
}
env.services.dialog.add(ImagePreviewDialog, {
images: images,
initialIndex: initialIndex,
title: title
});
}
registry.category("actions").add("fusion_claims.preview_image", previewImageAction);

View File

@@ -0,0 +1,667 @@
/** @odoo-module **/
// Fusion Claims - Google Maps Task View with Sidebar
// Copyright 2024-2026 Nexa Systems Inc.
// License OPL-1
import { registry } from "@web/core/registry";
import { standardViewProps } from "@web/views/standard_view_props";
import { useService } from "@web/core/utils/hooks";
import { useModelWithSampleData } from "@web/model/model";
import { useSetupAction } from "@web/search/action_hook";
import { usePager } from "@web/search/pager_hook";
import { useSearchBarToggler } from "@web/search/search_bar/search_bar_toggler";
import { RelationalModel } from "@web/model/relational_model/relational_model";
import { Layout } from "@web/search/layout";
import { SearchBar } from "@web/search/search_bar/search_bar";
import { CogMenu } from "@web/search/cog_menu/cog_menu";
import { _t } from "@web/core/l10n/translation";
import {
Component,
onMounted,
onPatched,
onWillUnmount,
useRef,
useState,
} from "@odoo/owl";
// ── Constants ───────────────────────────────────────────────────────
const STATUS_COLORS = {
scheduled: "#3b82f6",
en_route: "#f59e0b",
in_progress: "#8b5cf6",
completed: "#10b981",
cancelled: "#ef4444",
rescheduled: "#f97316",
};
const STATUS_LABELS = {
scheduled: "Scheduled",
en_route: "En Route",
in_progress: "In Progress",
completed: "Completed",
cancelled: "Cancelled",
rescheduled: "Rescheduled",
};
const STATUS_ICONS = {
scheduled: "fa-clock-o",
en_route: "fa-truck",
in_progress: "fa-wrench",
completed: "fa-check-circle",
cancelled: "fa-times-circle",
rescheduled: "fa-calendar",
};
// Date group keys
const GROUP_YESTERDAY = "yesterday";
const GROUP_TODAY = "today";
const GROUP_TOMORROW = "tomorrow";
const GROUP_THIS_WEEK = "this_week";
const GROUP_LATER = "later";
const GROUP_LABELS = {
[GROUP_YESTERDAY]: "Yesterday",
[GROUP_TODAY]: "Today",
[GROUP_TOMORROW]: "Tomorrow",
[GROUP_THIS_WEEK]: "This Week",
[GROUP_LATER]: "Upcoming",
};
// Pin colours by day group
const DAY_COLORS = {
[GROUP_YESTERDAY]: "#9ca3af", // Gray
[GROUP_TODAY]: "#ef4444", // Red
[GROUP_TOMORROW]: "#3b82f6", // Blue
[GROUP_THIS_WEEK]: "#10b981", // Green
[GROUP_LATER]: "#a855f7", // Purple
};
const DAY_ICONS = {
[GROUP_YESTERDAY]: "fa-history",
[GROUP_TODAY]: "fa-exclamation-circle",
[GROUP_TOMORROW]: "fa-arrow-right",
[GROUP_THIS_WEEK]: "fa-calendar",
[GROUP_LATER]: "fa-calendar-o",
};
// ── SVG numbered pin ────────────────────────────────────────────────
function numberedPinSvg(fill, number) {
const txt = String(number);
const fontSize = txt.length > 2 ? 13 : 16;
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="38" height="50" viewBox="0 0 38 50">` +
`<ellipse cx="19" cy="48" rx="8" ry="2.5" fill="rgba(0,0,0,.25)"/>` +
`<path d="M19 0C8.51 0 0 8.51 0 19c0 14 19 31 19 31s19-17 19-31C38 8.51 29.49 0 19 0z" fill="${fill}" stroke="#fff" stroke-width="2"/>` +
`<text x="19" y="${fontSize > 13 ? 24 : 23}" text-anchor="middle" fill="#fff" font-size="${fontSize}" font-family="Arial,Helvetica,sans-serif" font-weight="bold">#${txt}</text>` +
`</svg>`
);
}
function numberedPinUri(fill, number) {
return "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(numberedPinSvg(fill, number));
}
// ── Helpers ─────────────────────────────────────────────────────────
let _gmapsPromise = null;
function loadGoogleMaps(apiKey) {
if (window.google && window.google.maps) return Promise.resolve();
if (_gmapsPromise) return _gmapsPromise;
_gmapsPromise = new Promise((resolve, reject) => {
const cb = "_fc_gmap_" + Date.now();
window[cb] = () => { delete window[cb]; resolve(); };
const s = document.createElement("script");
s.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&callback=${cb}`;
s.async = true; s.defer = true;
s.onerror = () => { _gmapsPromise = null; reject(new Error("Google Maps script failed")); };
document.head.appendChild(s);
});
return _gmapsPromise;
}
function initialsOf(name) {
if (!name) return "?";
const p = name.trim().split(/\s+/);
return p.length >= 2
? (p[0][0] + p[p.length - 1][0]).toUpperCase()
: p[0].substring(0, 2).toUpperCase();
}
/** Return "YYYY-MM-DD" for a JS Date in local tz */
function localDateStr(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
/** Convert float hours (e.g. 13.5) to "1:30 PM" */
function floatToTime12(flt) {
if (!flt && flt !== 0) return "";
let h = Math.floor(flt);
let m = Math.round((flt - h) * 60);
if (m === 60) { h++; m = 0; }
const ampm = h >= 12 ? "PM" : "AM";
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${h12}:${String(m).padStart(2, "0")} ${ampm}`;
}
/** Classify a "YYYY-MM-DD" string into one of our group keys */
function classifyDate(dateStr) {
if (!dateStr) return GROUP_LATER;
const now = new Date();
const todayStr = localDateStr(now);
const yest = new Date(now);
yest.setDate(yest.getDate() - 1);
const yesterdayStr = localDateStr(yest);
const tmr = new Date(now);
tmr.setDate(tmr.getDate() + 1);
const tomorrowStr = localDateStr(tmr);
// End of this week (Sunday)
const endOfWeek = new Date(now);
endOfWeek.setDate(endOfWeek.getDate() + (7 - endOfWeek.getDay()));
const endOfWeekStr = localDateStr(endOfWeek);
if (dateStr === yesterdayStr) return GROUP_YESTERDAY;
if (dateStr === todayStr) return GROUP_TODAY;
if (dateStr === tomorrowStr) return GROUP_TOMORROW;
if (dateStr <= endOfWeekStr && dateStr > tomorrowStr) return GROUP_THIS_WEEK;
if (dateStr < yesterdayStr) return GROUP_YESTERDAY; // older lumped with yesterday
return GROUP_LATER;
}
/** Group + sort tasks, returning { groupKey: { label, tasks[], count } } */
function groupTasks(tasksData) {
// Sort by date ASC, time ASC
const sorted = [...tasksData].sort((a, b) => {
const da = a.scheduled_date || "";
const db = b.scheduled_date || "";
if (da !== db) return da < db ? -1 : 1;
return (a.time_start || 0) - (b.time_start || 0);
});
const groups = {};
const order = [GROUP_YESTERDAY, GROUP_TODAY, GROUP_TOMORROW, GROUP_THIS_WEEK, GROUP_LATER];
for (const key of order) {
groups[key] = {
key,
label: GROUP_LABELS[key],
dayColor: DAY_COLORS[key] || "#6b7280",
dayIcon: DAY_ICONS[key] || "fa-circle",
tasks: [],
count: 0,
};
}
let globalIdx = 0;
for (const task of sorted) {
globalIdx++;
const g = classifyDate(task.scheduled_date);
task._scheduleNum = globalIdx;
task._group = g;
task._dayColor = DAY_COLORS[g] || "#6b7280"; // Pin colour by day
task._statusColor = STATUS_COLORS[task.status] || "#6b7280";
task._statusLabel = STATUS_LABELS[task.status] || task.status || "";
task._statusIcon = STATUS_ICONS[task.status] || "fa-circle";
task._clientName = task.partner_id ? task.partner_id[1] : "N/A";
task._techName = task.technician_id ? task.technician_id[1] : "Unassigned";
task._typeLbl = task.task_type
? task.task_type.charAt(0).toUpperCase() + task.task_type.slice(1).replace("_", " ")
: "Task";
task._timeRange = `${task.time_start_display || floatToTime12(task.time_start)} - ${task.time_end_display || ""}`;
groups[g].tasks.push(task);
groups[g].count++;
}
// Return only non-empty groups in order
return order.map((k) => groups[k]).filter((g) => g.count > 0);
}
// ── Controller ──────────────────────────────────────────────────────
export class FusionTaskMapController extends Component {
static template = "fusion_claims.FusionTaskMapView";
static components = { Layout, SearchBar, CogMenu };
static props = {
...standardViewProps,
Model: Function,
modelParams: Object,
Renderer: { type: Function, optional: true },
buttonTemplate: String,
};
setup() {
this.orm = useService("orm");
this.actionService = useService("action");
this.mapRef = useRef("mapContainer");
this.state = useState({
loading: true,
error: null,
showTasks: true,
showTechnicians: true,
showTraffic: true,
taskCount: 0,
techCount: 0,
// Sidebar
sidebarOpen: true,
groups: [], // [{key, label, tasks[], count}]
collapsedGroups: {}, // {groupKey: true}
activeTaskId: null, // Highlighted task
// Day filters for map pins (which groups show on map)
visibleGroups: {
[GROUP_YESTERDAY]: false, // hidden by default
[GROUP_TODAY]: true,
[GROUP_TOMORROW]: true,
[GROUP_THIS_WEEK]: false, // hidden by default
[GROUP_LATER]: false, // hidden by default
},
});
// Yesterday collapsed by default in sidebar list
this.state.collapsedGroups[GROUP_YESTERDAY] = true;
this.state.collapsedGroups[GROUP_LATER] = true;
this.map = null;
this.taskMarkers = [];
this.taskMarkerMap = {}; // id → marker
this.techMarkers = [];
this.infoWindow = null;
this.apiKey = "";
this.tasksData = [];
this.locationsData = [];
const Model = this.props.Model;
this.model = useModelWithSampleData(Model, this.props.modelParams);
useSetupAction({ getLocalState: () => this._meta() });
usePager(() => ({
offset: this._meta().offset || 0,
limit: this._meta().limit || 80,
total: this.model.data?.count || this._meta().resCount || 0,
onUpdate: ({ offset, limit }) => this.model.load({ offset, limit }),
}));
this.searchBarToggler = useSearchBarToggler();
this.display = { controlPanel: {} };
this._lastDomainStr = "";
onMounted(async () => {
window.__fusionMapOpenTask = (id) => this.openTask(id);
await this._loadAndRender();
this._lastDomainStr = JSON.stringify(this._getDomain());
});
onPatched(() => {
const cur = JSON.stringify(this._getDomain());
if (cur !== this._lastDomainStr && this.map) {
this._lastDomainStr = cur;
this._onModelUpdate();
}
});
onWillUnmount(() => {
this._clearMarkers();
window.__fusionMapOpenTask = () => {};
});
}
// ── Model helpers (safe access across different Model types) ────
_meta() {
// RelationalModel uses .config, MapModel uses .metaData
return this.model.metaData || this.model.config || {};
}
_getDomain() {
const m = this._meta();
return m.domain || [];
}
// ── Data ─────────────────────────────────────────────────────────
async _loadAndRender() {
try {
const domain = this._getDomain();
const result = await this.orm.call("fusion.technician.task", "get_map_data", [domain]);
this.apiKey = result.api_key;
this.tasksData = result.tasks || [];
this.locationsData = result.locations || [];
this.state.taskCount = this.tasksData.length;
this.state.techCount = this.locationsData.length;
this.state.groups = groupTasks(this.tasksData);
if (!this.apiKey) {
this.state.error = _t("Google Maps API key not configured. Go to Settings > Fusion Claims.");
this.state.loading = false;
return;
}
await loadGoogleMaps(this.apiKey);
if (this.mapRef.el) this._initMap();
this.state.loading = false;
} catch (e) {
console.error("FusionTaskMap load error:", e);
this.state.error = String(e);
this.state.loading = false;
}
}
async _onModelUpdate() {
if (!this.map) return;
try {
const domain = this._getDomain();
const result = await this.orm.call("fusion.technician.task", "get_map_data", [domain]);
this.tasksData = result.tasks || [];
this.locationsData = result.locations || [];
this.state.taskCount = this.tasksData.length;
this.state.techCount = this.locationsData.length;
this.state.groups = groupTasks(this.tasksData);
this._renderMarkers();
} catch (e) {
console.error("FusionTaskMap update error:", e);
}
}
// ── Map ──────────────────────────────────────────────────────────
_initMap() {
if (!this.mapRef.el) return;
this.map = new google.maps.Map(this.mapRef.el, {
zoom: 10,
center: { lat: 43.7, lng: -79.4 },
mapTypeControl: true,
streetViewControl: false,
fullscreenControl: true,
zoomControl: true,
styles: [{ featureType: "poi", stylers: [{ visibility: "off" }] }],
});
// Traffic layer (on by default, toggleable)
this.trafficLayer = new google.maps.TrafficLayer();
this.trafficLayer.setMap(this.map);
this.infoWindow = new google.maps.InfoWindow();
// Close popup when clicking anywhere on the map
this.map.addListener("click", () => {
this.infoWindow.close();
});
// Clear sidebar highlight when popup closes (by any means)
this.infoWindow.addListener("closeclick", () => {
this.state.activeTaskId = null;
});
this._renderMarkers();
}
_clearMarkers() {
for (const m of this.taskMarkers) m.setMap(null);
for (const m of this.techMarkers) m.setMap(null);
this.taskMarkers = [];
this.taskMarkerMap = {};
this.techMarkers = [];
}
_renderMarkers() {
this._clearMarkers();
const bounds = new google.maps.LatLngBounds();
let hasBounds = false;
// Task pins: only show groups that are enabled in the day filter
if (this.state.showTasks) {
for (const group of this.state.groups) {
const groupVisible = this.state.visibleGroups[group.key] !== false;
for (const task of group.tasks) {
if (!task.address_lat || !task.address_lng) continue;
if (!groupVisible) continue;
const pos = { lat: task.address_lat, lng: task.address_lng };
const num = task._scheduleNum;
const color = task._dayColor;
const marker = new google.maps.Marker({
position: pos,
map: this.map,
title: `#${num} ${task.name} - ${task._clientName}`,
icon: {
url: numberedPinUri(color, num),
scaledSize: new google.maps.Size(38, 50),
anchor: new google.maps.Point(19, 50),
},
zIndex: 10 + num,
});
marker.addListener("click", () => this._openTaskPopup(task, marker));
this.taskMarkers.push(marker);
this.taskMarkerMap[task.id] = marker;
bounds.extend(pos);
hasBounds = true;
}
}
}
// Technician markers
if (this.state.showTechnicians) {
for (const loc of this.locationsData) {
if (!loc.latitude || !loc.longitude) continue;
const pos = { lat: loc.latitude, lng: loc.longitude };
const initials = initialsOf(loc.name);
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">` +
`<rect x="2" y="2" width="44" height="44" rx="12" ry="12" fill="#1d4ed8" stroke="#fff" stroke-width="3"/>` +
`<text x="24" y="30" text-anchor="middle" fill="#fff" font-size="17" font-family="Arial,Helvetica,sans-serif" font-weight="bold">${initials}</text>` +
`</svg>`;
const marker = new google.maps.Marker({
position: pos,
map: this.map,
title: loc.name,
icon: {
url: "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(svg),
scaledSize: new google.maps.Size(44, 44),
anchor: new google.maps.Point(22, 22),
},
zIndex: 100,
});
marker.addListener("click", () => {
this.infoWindow.setContent(`
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:200px;color:#1f2937;">
<div style="background:#1d4ed8;color:#fff;padding:10px 14px;">
<strong><i class="fa fa-user" style="margin-right:6px;"></i>${loc.name}</strong>
</div>
<div style="padding:12px 14px;font-size:13px;line-height:1.8;color:#1f2937;">
<div><strong style="color:#374151;">Last seen:</strong> <span style="color:#111827;">${loc.logged_at || "Unknown"}</span></div>
<div><strong style="color:#374151;">Accuracy:</strong> <span style="color:#111827;">${loc.accuracy ? Math.round(loc.accuracy) + "m" : "N/A"}</span></div>
</div>
</div>`);
this.infoWindow.open(this.map, marker);
});
this.techMarkers.push(marker);
bounds.extend(pos);
hasBounds = true;
}
}
if (hasBounds) {
this.map.fitBounds(bounds);
if (this.taskMarkers.length + this.techMarkers.length === 1) {
this.map.setZoom(14);
}
}
}
_openTaskPopup(task, marker) {
const c = task._dayColor;
const html = `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:270px;max-width:360px;color:#1f2937;position:relative;">
<div style="background:${c};color:#fff;padding:10px 14px;display:flex;justify-content:space-between;align-items:center;">
<strong style="font-size:14px;">#${task._scheduleNum} &nbsp;${task.name}</strong>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:11px;background:rgba(255,255,255,.2);padding:2px 8px;border-radius:10px;">${task._statusLabel}</span>
<button onclick="document.querySelector('.gm-ui-hover-effect')?.click()" title="Close"
style="background:rgba(255,255,255,.2);border:none;color:#fff;width:24px;height:24px;border-radius:50%;cursor:pointer;font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center;">
&times;
</button>
</div>
</div>
<div style="padding:12px 14px;font-size:13px;line-height:1.9;color:#1f2937;">
<div><strong style="color:#374151;">Client:</strong> <span style="color:#111827;">${task._clientName}</span></div>
<div><strong style="color:#374151;">Type:</strong> <span style="color:#111827;">${task._typeLbl}</span></div>
<div><strong style="color:#374151;">Technician:</strong> <span style="color:#111827;">${task._techName}</span></div>
<div><strong style="color:#374151;">Date:</strong> <span style="color:#111827;">${task.scheduled_date || ""}</span></div>
<div><strong style="color:#374151;">Time:</strong> <span style="color:#111827;">${task._timeRange}</span></div>
${task.address_display ? `<div><strong style="color:#374151;">Address:</strong> <span style="color:#111827;">${task.address_display}</span></div>` : ""}
${task.travel_time_minutes ? `<div><strong style="color:#374151;">Travel:</strong> <span style="color:#111827;">${task.travel_time_minutes} min</span></div>` : ""}
</div>
<div style="padding:8px 14px 12px;border-top:1px solid #e5e7eb;display:flex;gap:10px;">
<button onclick="window.__fusionMapOpenTask(${task.id})"
style="background:${c};color:#fff;border:none;padding:6px 16px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600;">
Open Task
</button>
<a href="https://www.google.com/maps/dir/?api=1&destination=${task.address_lat && task.address_lng ? task.address_lat + ',' + task.address_lng : encodeURIComponent(task.address_display || "")}"
target="_blank" style="color:${c};text-decoration:none;font-size:13px;font-weight:600;line-height:32px;">
Navigate &rarr;
</a>
</div>
</div>`;
this.infoWindow.setContent(html);
this.infoWindow.open(this.map, marker);
}
// ── Sidebar actions ─────────────────────────────────────────────
toggleSidebar() {
this.state.sidebarOpen = !this.state.sidebarOpen;
// Trigger map resize after CSS transition
if (this.map) {
setTimeout(() => google.maps.event.trigger(this.map, "resize"), 320);
}
}
toggleGroup(groupKey) {
this.state.collapsedGroups[groupKey] = !this.state.collapsedGroups[groupKey];
}
isGroupCollapsed(groupKey) {
return !!this.state.collapsedGroups[groupKey];
}
focusTask(taskId) {
this.state.activeTaskId = taskId;
const marker = this.taskMarkerMap[taskId];
if (marker && this.map) {
this.map.panTo(marker.getPosition());
this.map.setZoom(15);
// Find the task data
for (const g of this.state.groups) {
for (const t of g.tasks) {
if (t.id === taskId) {
this._openTaskPopup(t, marker);
return;
}
}
}
}
}
// ── Day filter toggle ────────────────────────────────────────────
toggleDayFilter(groupKey) {
this.state.visibleGroups[groupKey] = !this.state.visibleGroups[groupKey];
this._renderMarkers();
}
isGroupVisible(groupKey) {
return this.state.visibleGroups[groupKey] !== false;
}
showAllDays() {
for (const k of Object.keys(this.state.visibleGroups)) {
this.state.visibleGroups[k] = true;
}
this._renderMarkers();
}
showTodayOnly() {
for (const k of Object.keys(this.state.visibleGroups)) {
this.state.visibleGroups[k] = k === GROUP_TODAY;
}
this._renderMarkers();
}
// ── Top bar actions ─────────────────────────────────────────────
toggleTraffic() {
this.state.showTraffic = !this.state.showTraffic;
if (this.trafficLayer) {
this.trafficLayer.setMap(this.state.showTraffic ? this.map : null);
}
}
toggleTasks() {
this.state.showTasks = !this.state.showTasks;
this._renderMarkers();
}
toggleTechnicians() {
this.state.showTechnicians = !this.state.showTechnicians;
this._renderMarkers();
}
onRefresh() {
this.state.loading = true;
this._loadAndRender();
}
openTask(taskId) {
this.actionService.switchView("form", { resId: taskId });
}
createNewTask() {
this.actionService.doAction({
type: "ir.actions.act_window",
res_model: "fusion.technician.task",
views: [[false, "form"]],
target: "new",
context: { default_task_type: "delivery", dialog_size: "extra-large" },
}, {
onClose: () => {
// Refresh map data after dialog closes (task may have been created)
this.onRefresh();
},
});
}
}
window.__fusionMapOpenTask = () => {};
// ── Minimal ArchParser for <map> tags (no web_map dependency) ───────
class FusionMapArchParser {
parse(xmlDoc, models, modelName) {
const fieldNames = [];
const activeFields = {};
if (xmlDoc && xmlDoc.querySelectorAll) {
for (const fieldEl of xmlDoc.querySelectorAll("field")) {
const name = fieldEl.getAttribute("name");
if (name) {
fieldNames.push(name);
activeFields[name] = { attrs: {}, options: {} };
}
}
}
return { fieldNames, activeFields };
}
}
// ── View registration (self-contained, no @web_map dependency) ──────
const fusionTaskMapView = {
type: "map",
display_name: _t("Map"),
icon: "oi-view-map",
multiRecord: true,
searchMenuTypes: ["filter", "groupBy", "favorite"],
Controller: FusionTaskMapController,
Model: RelationalModel,
ArchParser: FusionMapArchParser,
buttonTemplate: "fusion_claims.FusionTaskMapView.Buttons",
props(genericProps, view, config) {
const { resModel, fields } = genericProps;
let archInfo = { fieldNames: [], activeFields: {} };
if (view && view.arch) {
archInfo = new FusionMapArchParser().parse(view.arch);
}
return {
...genericProps,
buttonTemplate: "fusion_claims.FusionTaskMapView.Buttons",
Model: RelationalModel,
modelParams: {
config: {
resModel,
fields,
activeFields: archInfo.activeFields || {},
isMonoRecord: false,
},
state: {
domain: genericProps.domain || [],
context: genericProps.context || {},
groupBy: genericProps.groupBy || [],
orderBy: genericProps.orderBy || [],
},
},
};
},
};
registry.category("views").add("fusion_task_map", fusionTaskMapView);

View File

@@ -0,0 +1,120 @@
/** @odoo-module **/
// Fusion Claims - Gallery Preview
// Uses Odoo's native FileViewer (same as chatter)
// Copyright 2024-2025 Nexa Systems Inc.
// License OPL-1
import { patch } from "@web/core/utils/patch";
import { Many2ManyBinaryField } from "@web/views/fields/many2many_binary/many2many_binary_field";
import { useFileViewer } from "@web/core/file_viewer/file_viewer_hook";
import { onMounted, onWillUnmount } from "@odoo/owl";
/**
* Patch Many2ManyBinaryField to use Odoo's native FileViewer
* when inside our gallery section (fc-gallery-content class)
*/
patch(Many2ManyBinaryField.prototype, {
setup() {
super.setup();
// Use Odoo's native file viewer hook (same as chatter)
this.fileViewer = useFileViewer();
// Bind the click handler
this._onGalleryClick = this._onGalleryClick.bind(this);
onMounted(() => {
// Find if we're inside a gallery section
const el = this.__owl__.bdom?.el;
if (el) {
const gallery = el.closest('.fc-gallery-content');
if (gallery) {
// Add click listener to intercept downloads
el.addEventListener('click', this._onGalleryClick, true);
this._galleryElement = el;
}
}
});
onWillUnmount(() => {
if (this._galleryElement) {
this._galleryElement.removeEventListener('click', this._onGalleryClick, true);
}
});
},
/**
* Handle clicks on attachments in gallery - intercept and use FileViewer
*/
_onGalleryClick(ev) {
// Check if click is anywhere inside an attachment box
const attachmentBox = ev.target.closest('.o_attachment');
if (!attachmentBox) {
return; // Not an attachment click
}
// Skip if clicking on the delete button
if (ev.target.closest('.o_attachment_delete')) {
return;
}
// Get file ID from any link or image within the attachment box
let fileId = null;
// Try to get from link href
const link = attachmentBox.querySelector('a[href*="/web/content/"], a[href*="/web/image/"]');
if (link) {
const href = link.getAttribute('href') || '';
const match = href.match(/\/web\/(?:content|image)\/(\d+)/);
if (match) {
fileId = parseInt(match[1], 10);
}
}
// Try to get from image src
if (!fileId) {
const imgEl = attachmentBox.querySelector('img[src*="/web/image/"]');
if (imgEl) {
const src = imgEl.getAttribute('src') || '';
const match = src.match(/\/web\/image\/(\d+)/);
if (match) {
fileId = parseInt(match[1], 10);
}
}
}
if (!fileId) {
return; // Couldn't determine file ID
}
// Prevent download
ev.preventDefault();
ev.stopPropagation();
// Get all files and transform to FileViewer format
const files = this.files.map(file => {
const mimetype = file.mimetype || 'image/png';
const isImage = mimetype.startsWith('image/');
const isPdf = mimetype === 'application/pdf';
return {
id: file.id,
name: file.name || 'File',
mimetype: mimetype,
isImage: isImage,
isPdf: isPdf,
isViewable: isImage || isPdf,
defaultSource: isImage ? `/web/image/${file.id}` : `/web/content/${file.id}`,
downloadUrl: `/web/content/${file.id}?download=true`,
};
});
// Find the clicked file and open FileViewer
const clickedFile = files.find(f => f.id === fileId);
if (clickedFile && this.fileViewer) {
this.fileViewer.open(clickedFile, files);
}
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
/** @odoo-module **/
// Fusion Claims - Preview Button Widget
// Copyright 2026 Nexa Systems Inc.
// License OPL-1
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { DocumentPreviewDialog } from "./document_preview";
class PreviewButtonComponent extends Component {
static template = "fusion_claims.PreviewButtonWidget";
static props = { "*": true };
setup() {
this.dialog = useService("dialog");
this.notification = useService("notification");
}
onClick() {
const record = this.props.record;
if (!record || !record.data) {
this.notification.add("No document to preview.", { type: "warning" });
return;
}
const attField = record.data.attachment_id;
let attachmentId = null;
if (Array.isArray(attField)) {
attachmentId = attField[0];
} else if (attField && typeof attField === "object" && attField.id) {
attachmentId = attField.id;
} else if (typeof attField === "number") {
attachmentId = attField;
}
const fileName = record.data.file_name || "Document Preview";
if (!attachmentId) {
this.notification.add("No document to preview.", { type: "warning" });
return;
}
this.dialog.add(DocumentPreviewDialog, {
attachmentId: attachmentId,
title: fileName,
});
}
}
registry.category("view_widgets").add("preview_button", {
component: PreviewButtonComponent,
});

View File

@@ -0,0 +1,63 @@
/** @odoo-module **/
/**
* Copyright 2024-2025 Nexa Systems Inc.
* License OPL-1 (Odoo Proprietary License v1.0)
*
* Custom Selection Field that filters out wizard-required statuses from dropdown.
* These statuses can only be set via dedicated action buttons that open reason wizards.
*/
import { registry } from "@web/core/registry";
import { SelectionField, selectionField } from "@web/views/fields/selection/selection_field";
// Statuses that can ONLY be set via buttons/wizards
// These are hidden from the dropdown to enforce workflow integrity
const CONTROLLED_STATUSES = [
// Early workflow stages
'assessment_scheduled', // Must use "Schedule Assessment" button
'assessment_completed', // Must use "Complete Assessment" button
'application_received', // Must use "Application Received" button
'ready_submission', // Must use "Ready for Submission" button
// Submission and approval stages
'submitted', // Must use "Submit Application" button
'resubmitted', // Must use "Submit Application" button
'approved', // Must use "Mark as Approved" button
'approved_deduction', // Must use "Mark as Approved" button
// Billing stages
'ready_bill', // Must use "Ready to Bill" button
'billed', // Must use "Mark as Billed" button
'case_closed', // Must use "Close Case" button
// Special statuses (require reason wizard)
'on_hold', // Must use "Put On Hold" button
'withdrawn', // Must use "Withdraw" button
'denied', // Must use "Denied" button
'cancelled', // Must use "Cancel" button
'needs_correction', // Must use "Needs Correction" button
];
export class FilteredStatusSelectionField extends SelectionField {
/**
* Override to filter out wizard-required statuses from the options.
* The current status is always kept so the field displays correctly.
*/
get options() {
const allOptions = super.options;
const currentValue = this.props.record.data[this.props.name];
// Filter out wizard-required statuses, but keep current value
return allOptions.filter(option => {
const [value] = option;
// Keep the option if it's the current value OR if it's not a controlled status
return value === currentValue || !CONTROLLED_STATUSES.includes(value);
});
}
}
FilteredStatusSelectionField.template = "web.SelectionField";
export const filteredStatusSelectionField = {
...selectionField,
component: FilteredStatusSelectionField,
};
registry.category("fields").add("filtered_status_selection", filteredStatusSelectionField);

View File

@@ -0,0 +1,30 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { TaxTotalsComponent } from "@account/components/tax_totals/tax_totals";
/**
* Patch TaxTotalsComponent to handle cases where subtotals is undefined
* This fixes the "Invalid loop expression: 'undefined' is not iterable" error
* that occurs when invoices have no tax configuration.
*/
patch(TaxTotalsComponent.prototype, {
formatData(props) {
// Call the original formatData method
super.formatData(props);
// If totals exists but subtotals is undefined, set it to empty array
if (this.totals && this.totals.subtotals === undefined) {
this.totals.subtotals = [];
}
// Also ensure each subtotal has tax_groups array
if (this.totals && this.totals.subtotals) {
for (const subtotal of this.totals.subtotals) {
if (subtotal.tax_groups === undefined) {
subtotal.tax_groups = [];
}
}
}
}
});