Initial commit
This commit is contained in:
192
fusion_claims/static/src/js/attachment_image_compress.js
Normal file
192
fusion_claims/static/src/js/attachment_image_compress.js
Normal 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);
|
||||
},
|
||||
});
|
||||
22
fusion_claims/static/src/js/calendar_store_hours.js
Normal file
22
fusion_claims/static/src/js/calendar_store_hours.js
Normal 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;
|
||||
},
|
||||
});
|
||||
40
fusion_claims/static/src/js/chatter_resize.js
Normal file
40
fusion_claims/static/src/js/chatter_resize.js
Normal 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();
|
||||
}
|
||||
296
fusion_claims/static/src/js/document_preview.js
Normal file
296
fusion_claims/static/src/js/document_preview.js
Normal 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(/<(\/?)([\w:-]+)/g,
|
||||
'<$1<span class="xml-tag">$2</span>');
|
||||
|
||||
// Highlight attributes
|
||||
str = str.replace(/([\w:-]+)=("[^&]*")/g,
|
||||
'<span class="xml-attr">$1</span>=<span class="xml-value">$2</span>');
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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);
|
||||
667
fusion_claims/static/src/js/fusion_task_map_view.js
Normal file
667
fusion_claims/static/src/js/fusion_task_map_view.js
Normal 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} ${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;">
|
||||
×
|
||||
</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 →
|
||||
</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);
|
||||
120
fusion_claims/static/src/js/gallery_preview.js
Normal file
120
fusion_claims/static/src/js/gallery_preview.js
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
1480
fusion_claims/static/src/js/google_address_autocomplete.js
Normal file
1480
fusion_claims/static/src/js/google_address_autocomplete.js
Normal file
File diff suppressed because it is too large
Load Diff
53
fusion_claims/static/src/js/preview_button_widget.js
Normal file
53
fusion_claims/static/src/js/preview_button_widget.js
Normal 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,
|
||||
});
|
||||
63
fusion_claims/static/src/js/status_selection_filter.js
Normal file
63
fusion_claims/static/src/js/status_selection_filter.js
Normal 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);
|
||||
30
fusion_claims/static/src/js/tax_totals_patch.js
Normal file
30
fusion_claims/static/src/js/tax_totals_patch.js
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user