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,367 @@
// =====================================================================
// Fusion Task Map View - Sidebar + Google Maps
// Theme-aware: uses Odoo/Bootstrap variables for dark mode support
// =====================================================================
$sidebar-width: 340px;
$transition-speed: .25s;
.o_fusion_task_map_view {
height: 100%;
.o_content {
height: 100%;
display: flex;
flex-direction: column;
}
}
// ── Main wrapper: sidebar + map side by side ────────────────────────
.fc_map_wrapper {
display: flex;
flex-direction: row;
height: 100%;
min-height: 0;
overflow: hidden;
position: relative;
}
// ── Sidebar ─────────────────────────────────────────────────────────
.fc_sidebar {
width: $sidebar-width;
min-width: $sidebar-width;
max-width: $sidebar-width;
background: var(--o-view-background-color, $o-view-background-color);
border-right: 1px solid $border-color;
display: flex;
flex-direction: column;
transition: width $transition-speed ease, min-width $transition-speed ease,
max-width $transition-speed ease, opacity $transition-speed ease;
overflow: hidden;
&--collapsed {
width: 0;
min-width: 0;
max-width: 0;
opacity: 0;
border-right: none;
}
}
.fc_sidebar_header {
padding: 14px 16px 12px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
h6 {
font-size: 14px;
color: $headings-color;
}
}
.fc_sidebar_body {
flex: 1 1 auto;
overflow-y: auto;
overflow-x: hidden;
padding: 6px 0;
&::-webkit-scrollbar { width: 5px; }
&::-webkit-scrollbar-track { background: transparent; }
&::-webkit-scrollbar-thumb { background: $border-color; border-radius: 4px; }
}
.fc_sidebar_footer {
padding: 10px 16px;
border-top: 1px solid $border-color;
flex-shrink: 0;
}
.fc_sidebar_empty {
text-align: center;
padding: 40px 20px;
color: $text-muted;
}
// ── Day filter chips ────────────────────────────────────────────────
.fc_day_filters {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.fc_day_chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 10px;
font-size: 11px;
font-weight: 600;
border: 1px solid $border-color;
border-radius: 12px;
background: transparent;
color: $text-muted;
cursor: pointer;
transition: all .15s;
line-height: 18px;
&:hover {
border-color: rgba($primary, .3);
color: $body-color;
}
&--active {
color: #fff !important;
border-color: transparent !important;
}
&--all {
color: $body-color;
font-weight: 500;
&:hover { background: rgba($primary, .1); }
}
}
.fc_day_chip_count {
font-size: 10px;
opacity: .8;
}
.fc_group_hidden_tag {
font-size: 9px;
text-transform: uppercase;
letter-spacing: .5px;
color: $text-muted;
background: rgba($secondary, .1);
padding: 0 5px;
border-radius: 3px;
margin-left: 4px;
font-weight: 500;
}
// Collapsed toggle button (floating)
.fc_sidebar_toggle_btn {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
z-index: 15;
background: var(--o-view-background-color, $o-view-background-color);
border: 1px solid $border-color;
border-left: none;
border-radius: 0 8px 8px 0;
padding: 12px 6px;
cursor: pointer;
box-shadow: 2px 0 6px rgba(0,0,0,.08);
color: $text-muted;
transition: background .15s;
&:hover {
background: $o-gray-100;
color: $body-color;
}
}
// ── Group headers ───────────────────────────────────────────────────
.fc_group_header {
display: flex;
align-items: center;
padding: 8px 16px;
cursor: pointer;
user-select: none;
font-weight: 600;
font-size: 12px;
color: $text-muted;
text-transform: uppercase;
letter-spacing: .5px;
background: rgba($secondary, .08);
border-bottom: 1px solid $border-color;
transition: background .15s;
&:hover {
background: rgba($secondary, .15);
}
.fa-caret-right,
.fa-caret-down {
width: 14px;
text-align: center;
font-size: 13px;
}
}
.fc_group_label {
flex: 1;
}
.fc_group_badge {
background: rgba($secondary, .2);
color: $body-color;
font-size: 10px;
font-weight: 700;
padding: 1px 7px;
border-radius: 10px;
min-width: 20px;
text-align: center;
}
// ── Task cards ──────────────────────────────────────────────────────
.fc_group_tasks {
padding: 4px 0;
}
.fc_task_card {
margin: 3px 10px;
padding: 10px 12px;
background: var(--o-view-background-color, $o-view-background-color);
border: 1px solid $border-color;
border-radius: 8px;
cursor: pointer;
transition: all .15s;
position: relative;
&:hover {
background: rgba($primary, .05);
border-color: rgba($primary, .2);
box-shadow: 0 1px 4px rgba(0,0,0,.06);
}
&--active {
background: rgba($primary, .1) !important;
border-color: rgba($primary, .35) !important;
box-shadow: 0 0 0 2px rgba($primary, .15);
}
}
.fc_task_card_top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.fc_task_num {
display: inline-block;
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 1px 8px;
border-radius: 4px;
line-height: 18px;
}
.fc_task_status {
font-size: 11px;
font-weight: 600;
}
.fc_task_client {
font-size: 13px;
font-weight: 600;
color: $headings-color;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fc_task_meta {
display: flex;
gap: 12px;
font-size: 11px;
color: $body-color;
margin-bottom: 3px;
.fa { opacity: .5; }
}
.fc_task_detail {
font-size: 11px;
color: $body-color;
margin-bottom: 2px;
.fa { opacity: .5; }
}
.fc_task_address {
font-size: 10px;
color: $text-muted;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.fc_task_travel {
display: inline-block;
margin-top: 4px;
font-size: 10px;
color: $body-color;
background: rgba($secondary, .1);
padding: 1px 8px;
border-radius: 4px;
.fa { opacity: .5; }
}
// ── Map area ────────────────────────────────────────────────────────
.fc_map_area {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-width: 0;
position: relative;
}
.fc_map_legend_bar {
flex: 0 0 auto;
font-size: 12px;
min-height: 40px;
}
.fc_map_container {
flex: 1 1 auto;
position: relative;
min-height: 400px;
}
// ── Google Maps InfoWindow override (always light bg) ───────────────
// InfoWindow is rendered by Google outside our DOM; we style via
// the .gm-style-iw container that Google injects.
.gm-style-iw-d {
overflow: auto !important;
}
.gm-style .gm-style-iw-c {
padding: 0 !important;
border-radius: 10px !important;
}
// ── Responsive ──────────────────────────────────────────────────────
@media (max-width: 768px) {
.fc_map_wrapper {
flex-direction: column;
}
.fc_sidebar {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
max-height: 40vh;
border-right: none;
border-bottom: 1px solid $border-color;
&--collapsed {
max-height: 0;
opacity: 0;
}
}
.fc_sidebar_toggle_btn {
top: auto;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
border-radius: 8px;
border: 1px solid $border-color;
padding: 8px 16px;
}
.fc_map_area {
flex: 1;
min-height: 300px;
}
}

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 = [];
}
}
}
}
});

View File

@@ -0,0 +1,872 @@
// Fusion Central - Backend Styles
// Copyright 2024-2025 Nexa Systems Inc.
// License OPL-1
.o_fusion_central {
// Settings page styling
.fc-settings-section {
margin-bottom: 20px;
h5 {
color: #0077b6;
border-bottom: 2px solid #0077b6;
padding-bottom: 5px;
margin-bottom: 15px;
}
}
// Status indicators
.fc-status-created {
color: #28a745;
font-weight: 500;
}
.fc-status-pending {
color: #ffc107;
font-weight: 500;
}
}
// ADP portion columns styling
.o_list_view {
.o_field_monetary.fc-adp-portion {
color: #0077b6;
font-weight: 500;
}
.o_field_monetary.fc-client-portion {
color: #28a745;
font-weight: 500;
}
}
// =============================================================================
// STATUS BUTTONS: Theme-friendly (light + dark mode)
// Uses Odoo CSS variables with safe fallbacks.
// =============================================================================
// Good / confirmed / within period (green tint)
.fc-btn-status-good {
background-color: rgba(40, 167, 69, 0.12) !important;
color: #1e7e34 !important;
border: 1px solid rgba(40, 167, 69, 0.35) !important;
&:hover, &:focus {
background-color: rgba(40, 167, 69, 0.22) !important;
color: #1e7e34 !important;
border-color: rgba(40, 167, 69, 0.5) !important;
}
.fa { color: inherit !important; }
}
// Bad / not confirmed / overdue (red tint)
.fc-btn-status-bad {
background-color: rgba(220, 53, 69, 0.12) !important;
color: #bd2130 !important;
border: 1px solid rgba(220, 53, 69, 0.35) !important;
&:hover, &:focus {
background-color: rgba(220, 53, 69, 0.22) !important;
color: #bd2130 !important;
border-color: rgba(220, 53, 69, 0.5) !important;
}
.fa { color: inherit !important; }
}
// Dark mode overrides
html.dark, .o_dark {
.fc-btn-status-good {
background-color: rgba(40, 167, 69, 0.18) !important;
color: #6fcf87 !important;
border-color: rgba(40, 167, 69, 0.4) !important;
&:hover, &:focus {
background-color: rgba(40, 167, 69, 0.28) !important;
color: #6fcf87 !important;
}
}
.fc-btn-status-bad {
background-color: rgba(220, 53, 69, 0.18) !important;
color: #f08a93 !important;
border-color: rgba(220, 53, 69, 0.4) !important;
&:hover, &:focus {
background-color: rgba(220, 53, 69, 0.28) !important;
color: #f08a93 !important;
}
}
}
// Also support Odoo's color-scheme media query for dark mode
@media (prefers-color-scheme: dark) {
.o_web_client:not(.o_light) {
.fc-btn-status-good {
background-color: rgba(40, 167, 69, 0.18) !important;
color: #6fcf87 !important;
border-color: rgba(40, 167, 69, 0.4) !important;
&:hover, &:focus {
background-color: rgba(40, 167, 69, 0.28) !important;
color: #6fcf87 !important;
}
}
.fc-btn-status-bad {
background-color: rgba(220, 53, 69, 0.18) !important;
color: #f08a93 !important;
border-color: rgba(220, 53, 69, 0.4) !important;
&:hover, &:focus {
background-color: rgba(220, 53, 69, 0.28) !important;
color: #f08a93 !important;
}
}
}
}
// =============================================================================
// SALE ORDER LINE LIST: Column width control
// Odoo 19 ignores the XML width attribute on list fields.
// We use CSS on th[data-name] with table-layout:fixed to force widths.
// Product column has NO explicit width so it absorbs all remaining space.
// =============================================================================
.o_field_one2many[name="order_line"] .o_list_table {
table-layout: fixed !important;
width: 100% !important;
// ---- Product column: gets ALL remaining space (no width set) ----
// Truncate long product names with ellipsis
th[data-name="product_template_id"],
td[name="product_template_id"],
th[data-name="product_id"],
td[name="product_id"] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// ---- Serial Number: generous width ----
th[data-name="x_fc_serial_number"] { width: 140px !important; }
// ---- Quantity columns ----
th[data-name="product_uom_qty"] { width: 55px !important; }
th[data-name="qty_delivered"] { width: 55px !important; }
th[data-name="qty_invoiced"] { width: 55px !important; }
// ---- UoM ----
th[data-name="product_uom_id"] { width: 50px !important; }
// ---- Price / Discount / Tax / Subtotal ----
th[data-name="price_unit"] { width: 80px !important; }
th[data-name="tax_ids"] { width: 70px !important; }
th[data-name="discount"] { width: 45px !important; }
th[data-name="price_subtotal"] { width: 90px !important; }
// ---- ADP / Client Portion ----
th[data-name="x_fc_adp_portion"] { width: 80px !important; }
th[data-name="x_fc_client_portion"] { width: 80px !important; }
// ---- sale_margin optional columns ----
th[data-name="purchase_price"] { width: 70px !important; }
th[data-name="margin"] { width: 65px !important; }
th[data-name="margin_percent"] { width: 55px !important; }
// ---- Description (hidden by default, but set width in case user shows it) ----
th[data-name="name"] { width: 120px !important; }
// Tax tags: compact badges
td[name="tax_ids"] .badge {
font-size: 0.72em;
padding: 2px 4px;
}
// All cells: allow text truncation
td {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// Responsive: scale text on smaller screens
@media (max-width: 1400px) {
.o_field_one2many[name="order_line"] .o_list_table {
font-size: 0.9em;
}
}
@media (max-width: 1200px) {
.o_field_one2many[name="order_line"] .o_list_table {
font-size: 0.85em;
}
}
// Form view styling for ADP fields
.o_form_view {
.fc-adp-totals {
background-color: #f8f9fa;
border-left: 4px solid #0077b6;
padding: 10px;
margin: 10px 0;
.fc-total-label {
font-weight: 600;
color: #495057;
}
.fc-total-value {
font-size: 1.1em;
font-weight: 700;
}
.fc-adp-value {
color: #0077b6;
}
.fc-client-value {
color: #28a745;
}
}
}
// ADP Summary Line Details - constrain product column width
.o_fc_line_details {
.o_list_table {
table-layout: fixed !important;
width: 100% !important;
// Product column - first column
td:first-child,
th:first-child {
max-width: 300px !important;
width: 40% !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
// Other columns - auto size
td:not(:first-child),
th:not(:first-child) {
width: auto !important;
white-space: nowrap !important;
}
}
}
// =============================================================================
// CHATTER WIDTH CUSTOMIZATION
// 80%/20% split ONLY on desktop (>= 992px).
// On mobile/tablet, Odoo's default stacking (chatter below form) takes over.
// ONLY applies to main action forms, NOT modal dialogs/wizards.
// =============================================================================
@media (min-width: 992px) {
// Only apply to non-modal forms (forms in the main action area, not in dialogs)
.o_action_manager > .o_action > .o_form_view .o_form_renderer {
display: flex !important;
flex-wrap: nowrap !important;
// Form content takes 80% of space
> .o_form_sheet_bg {
flex: 0 0 80% !important;
width: 80% !important;
min-width: 0 !important;
max-width: 80% !important;
}
// Chatter container - 20% of screen
> .o-mail-ChatterContainer,
> .o-mail-Form-chatter,
> .o-aside {
flex: 0 0 20% !important;
width: 20% !important;
min-width: 20% !important;
max-width: 20% !important;
}
}
// Additional backup selectors for chatter (non-modal only)
.o_action_manager .o-mail-ChatterContainer.o-aside {
flex: 0 0 20% !important;
width: 20% !important;
min-width: 20% !important;
max-width: 20% !important;
}
// Force the form sheet content to expand within its container (non-modal only)
.o_action_manager .o_form_sheet_bg {
max-width: none !important;
}
// Also target the inner form sheet (non-modal only)
.o_action_manager .o_form_sheet {
max-width: none !important;
width: 100% !important;
}
}
// Make chatter content more compact (all screen sizes)
.o-mail-Thread {
.o-mail-Message {
padding: 6px 10px !important;
font-size: 0.9em;
}
}
// Compact activity section
.o-mail-Activity {
padding: 4px 8px !important;
}
// =============================================================================
// Icon-only chatter topbar buttons (ALL screen sizes)
// "Send message" and "Log note" have RAW TEXT inside (no <span>).
// "WhatsApp" and "Activity" wrap text in <span>.
// We use font-size:0 to hide text, then inject icons via ::before.
// =============================================================================
.o-mail-Chatter-topbar {
gap: 4px;
// --- Send message (raw text, no span) -> envelope icon ---
.o-mail-Chatter-sendMessage {
font-size: 0 !important;
padding: 8px 12px !important;
min-width: auto;
line-height: 1;
&::before {
font-family: "Font Awesome 5 Free", FontAwesome;
font-weight: 900;
font-size: 15px;
content: "\f0e0"; // fa-envelope
}
}
// --- Log note (raw text, no span) -> edit icon ---
.o-mail-Chatter-logNote {
font-size: 0 !important;
padding: 8px 12px !important;
min-width: auto;
line-height: 1;
&::before {
font-family: "Font Awesome 5 Free", FontAwesome;
font-weight: 900;
font-size: 15px;
content: "\f044"; // fa-edit / fa-pencil-square-o
}
}
// --- WhatsApp (text in <span>, target via hotkey) -> WhatsApp SVG ---
button[data-hotkey="shift+w"] {
> span { display: none !important; }
padding: 8px 12px !important;
min-width: auto;
line-height: 1;
&::before {
content: "";
display: inline-block;
width: 17px;
height: 17px;
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z'/%3E%3C/svg%3E");
-webkit-mask-size: contain;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
background-color: currentColor;
}
}
// --- Activity (text in <span>) -> calendar icon ---
.o-mail-Chatter-activity {
> span { display: none !important; }
padding: 8px 12px !important;
min-width: auto;
line-height: 1;
&::before {
font-family: "Font Awesome 5 Free", FontAwesome;
font-weight: 900;
font-size: 15px;
content: "\f073"; // fa-calendar
}
}
// --- Message Authorizer (text in <span>) -> custom SVG ---
.o-mail-Chatter-messageAuthorizer {
> span { display: none !important; }
padding: 8px 12px !important;
min-width: auto;
line-height: 1;
&::before {
content: "";
display: inline-block;
width: 17px;
height: 17px;
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 50'%3E%3Cpath d='M 25 -0.03125 C 11.839844 -0.03125 10.148438 4.851563 10 5.40625 C 9.988281 5.453125 9.976563 5.484375 9.96875 5.53125 L 7.96875 21.5 C 7.929688 21.828125 8.070313 22.148438 8.3125 22.375 C 8.410156 22.464844 8.503906 22.546875 8.625 22.59375 C 8.363281 23.386719 8.015625 24.71875 7.75 27.03125 C 7.75 27.042969 7.75 27.050781 7.75 27.0625 C 7.304688 27.746094 7 28.65625 7 29.8125 C 7 32.0625 8.582031 33.878906 10.65625 34.40625 C 12.003906 37.898438 13.675781 41.625 15.90625 44.59375 C 18.230469 47.683594 21.238281 50 25 50 C 28.761719 50 31.769531 47.683594 34.09375 44.59375 C 36.324219 41.625 37.996094 37.898438 39.34375 34.40625 C 41.429688 33.886719 43 32.070313 43 29.8125 C 43 28.613281 42.699219 27.6875 42.25 27 C 41.984375 24.707031 41.636719 23.382813 41.375 22.59375 C 41.496094 22.546875 41.589844 22.464844 41.6875 22.375 C 41.929688 22.148438 42.074219 21.828125 42.03125 21.5 L 40.03125 5.53125 C 40.023438 5.484375 40.011719 5.453125 40 5.40625 C 39.851563 4.851563 38.160156 -0.03125 25 -0.03125 Z M 24 6 L 26 6 L 26 10 L 30 10 L 30 12 L 26 12 L 26 16 L 24 16 L 24 12 L 20 12 L 20 10 L 24 10 Z M 25 20.78125 C 29.371094 20.78125 34.777344 21.605469 38 22.15625 L 38 27.65625 L 39.15625 27.46875 C 39.15625 27.46875 39.628906 27.390625 40.0625 27.59375 C 40.496094 27.796875 41 28.15625 41 29.8125 C 41 31.300781 39.898438 32.449219 38.5 32.59375 L 37.90625 32.65625 L 37.6875 33.25 C 36.34375 36.785156 34.621094 40.554688 32.5 43.375 C 30.378906 46.195313 27.941406 48 25 48 C 22.058594 48 19.621094 46.195313 17.5 43.375 C 15.378906 40.554688 13.6875 36.785156 12.34375 33.25 L 12.125 32.65625 L 11.5 32.59375 C 10.097656 32.449219 9 31.300781 9 29.8125 C 9 28.234375 9.484375 27.878906 9.9375 27.65625 C 10.390625 27.433594 10.875 27.46875 10.875 27.46875 L 12 27.59375 L 12 22.15625 C 15.222656 21.605469 20.625 20.78125 25 20.78125 Z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 50'%3E%3Cpath d='M 25 -0.03125 C 11.839844 -0.03125 10.148438 4.851563 10 5.40625 C 9.988281 5.453125 9.976563 5.484375 9.96875 5.53125 L 7.96875 21.5 C 7.929688 21.828125 8.070313 22.148438 8.3125 22.375 C 8.410156 22.464844 8.503906 22.546875 8.625 22.59375 C 8.363281 23.386719 8.015625 24.71875 7.75 27.03125 C 7.75 27.042969 7.75 27.050781 7.75 27.0625 C 7.304688 27.746094 7 28.65625 7 29.8125 C 7 32.0625 8.582031 33.878906 10.65625 34.40625 C 12.003906 37.898438 13.675781 41.625 15.90625 44.59375 C 18.230469 47.683594 21.238281 50 25 50 C 28.761719 50 31.769531 47.683594 34.09375 44.59375 C 36.324219 41.625 37.996094 37.898438 39.34375 34.40625 C 41.429688 33.886719 43 32.070313 43 29.8125 C 43 28.613281 42.699219 27.6875 42.25 27 C 41.984375 24.707031 41.636719 23.382813 41.375 22.59375 C 41.496094 22.546875 41.589844 22.464844 41.6875 22.375 C 41.929688 22.148438 42.074219 21.828125 42.03125 21.5 L 40.03125 5.53125 C 40.023438 5.484375 40.011719 5.453125 40 5.40625 C 39.851563 4.851563 38.160156 -0.03125 25 -0.03125 Z M 24 6 L 26 6 L 26 10 L 30 10 L 30 12 L 26 12 L 26 16 L 24 16 L 24 12 L 20 12 L 20 10 L 24 10 Z M 25 20.78125 C 29.371094 20.78125 34.777344 21.605469 38 22.15625 L 38 27.65625 L 39.15625 27.46875 C 39.15625 27.46875 39.628906 27.390625 40.0625 27.59375 C 40.496094 27.796875 41 28.15625 41 29.8125 C 41 31.300781 39.898438 32.449219 38.5 32.59375 L 37.90625 32.65625 L 37.6875 33.25 C 36.34375 36.785156 34.621094 40.554688 32.5 43.375 C 30.378906 46.195313 27.941406 48 25 48 C 22.058594 48 19.621094 46.195313 17.5 43.375 C 15.378906 40.554688 13.6875 36.785156 12.34375 33.25 L 12.125 32.65625 L 11.5 32.59375 C 10.097656 32.449219 9 31.300781 9 29.8125 C 9 28.234375 9.484375 27.878906 9.9375 27.65625 C 10.390625 27.433594 10.875 27.46875 10.875 27.46875 L 12 27.59375 L 12 22.15625 C 15.222656 21.605469 20.625 20.78125 25 20.78125 Z'/%3E%3C/svg%3E");
-webkit-mask-size: contain;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
background-color: currentColor;
}
}
// --- Mic button (already icon-only from fusion_notes) ---
.fusion-notes-mic-btn,
.o-mail-Chatter-voiceNote {
padding: 8px 12px !important;
}
}
// =============================================================================
// XML VIEWER STYLES
// =============================================================================
.xml-viewer-content {
overflow: auto;
background: #1e1e1e;
.xml-code {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
font-size: 13px;
line-height: 1.5;
color: #d4d4d4;
background: transparent;
white-space: pre;
tab-size: 2;
}
// Syntax highlighting colors (VS Code dark theme inspired)
.xml-tag {
color: #569cd6;
font-weight: 500;
}
.xml-attr {
color: #9cdcfe;
}
.xml-value {
color: #ce9178;
}
}
// =============================================================================
// ADP DOCUMENTS TILE LAYOUT
// =============================================================================
.fc-document-tiles {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 16px 0;
}
.fc-document-tile {
width: 220px;
min-width: 220px;
background: var(--o-bg-card, var(--bs-body-bg));
border: 1px solid var(--o-border-color, var(--bs-border-color));
border-radius: 8px;
overflow: hidden;
transition: all 0.2s ease;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
&:hover {
border-color: var(--o-action, var(--bs-primary));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
// Preview area
.fc-tile-preview {
height: 140px;
display: flex;
align-items: center;
justify-content: center;
background: var(--o-bg-200, var(--bs-secondary-bg));
border-bottom: 1px solid var(--o-border-color, var(--bs-border-color));
position: relative;
.fc-pdf-icon {
font-size: 48px;
color: var(--o-danger, var(--bs-danger));
}
.fc-xml-icon {
font-size: 48px;
color: var(--o-info, var(--bs-info));
}
.fc-empty-icon {
font-size: 48px;
opacity: 0.35;
}
.fc-thumbnail {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.fc-upload-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
i {
font-size: 32px;
color: white;
margin-bottom: 8px;
}
span {
color: white;
font-size: 12px;
font-weight: 500;
}
}
}
&.fc-tile-empty:hover .fc-upload-overlay {
opacity: 1;
}
// Tile info area
.fc-tile-info {
padding: 12px;
text-align: center;
.fc-tile-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
margin-bottom: 6px;
line-height: 1.3;
}
.fc-tile-filename {
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fc-tile-empty-text {
font-size: 11px;
opacity: 0.5;
font-style: italic;
}
}
// Actions bar
.fc-tile-actions {
display: flex;
justify-content: center;
border-top: 1px solid var(--o-border-color, var(--bs-border-color));
background: var(--o-bg-100, var(--bs-tertiary-bg));
padding: 8px;
button {
flex: 1;
padding: 8px;
border: none;
background: transparent;
font-size: 14px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--o-action, var(--bs-primary));
color: white;
}
&:not(:last-child) {
border-right: 1px solid var(--o-border-color, var(--bs-border-color));
}
}
}
// Has file state
&.fc-tile-filled {
border-color: var(--o-success, var(--bs-success));
}
// Required field indicator
&:has(.o_required_modifier) {
.fc-tile-label::after {
content: " *";
color: var(--o-danger, var(--bs-danger));
font-weight: bold;
}
&:not(:has(.o_field_binary[value])) {
border-color: var(--o-warning, var(--bs-warning));
}
}
}
// Section headers for document groups
.fc-doc-section {
margin-bottom: 24px;
.fc-doc-section-header {
font-size: 14px;
font-weight: 600;
border-bottom: 2px solid var(--o-action, var(--bs-primary));
padding-bottom: 8px;
margin-bottom: 12px;
display: flex;
align-items: center;
i {
margin-right: 8px;
color: var(--o-action, var(--bs-primary));
}
}
}
// Style the upload field in tiles
.fc-tile-upload-field {
width: 100%;
.o_select_file_button {
width: 100%;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
font-size: 12px !important;
padding: 8px !important;
&:hover {
background: var(--o-action, var(--bs-primary)) !important;
color: white !important;
}
}
.o_file_name {
display: none !important;
}
.o_input_file {
display: none !important;
}
&.o_field_binary {
display: flex;
justify-content: center;
}
.o_form_binary_progress {
width: 100%;
padding: 4px;
}
}
// Fix button styling in tiles
.fc-document-tile {
.btn-link {
text-decoration: none !important;
&:hover {
text-decoration: none !important;
}
.fc-pdf-icon:hover {
opacity: 0.7;
transform: scale(1.1);
transition: all 0.2s ease;
}
}
}
// =============================================================================
// APPROVAL SCREENSHOTS GALLERY
// =============================================================================
.fc-gallery-section {
background: var(--o-bg-100, var(--bs-tertiary-bg));
border: 2px solid var(--o-border-color, var(--bs-border-color));
border-radius: 8px;
padding: 16px;
margin-top: 8px;
.fc-gallery-header {
padding-bottom: 12px;
.fc-tile-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.badge {
font-size: 11px;
}
.btn {
font-size: 12px;
padding: 4px 10px;
}
}
.fc-gallery-content {
padding-top: 8px;
// Style the many2many_binary widget as a gallery
.o_field_many2many_binary {
display: flex !important;
flex-wrap: wrap !important;
gap: 12px !important;
justify-content: flex-start !important;
align-items: flex-start !important;
padding-top: 4px !important;
// Each file item as a thumbnail card
.o_attachments {
display: flex !important;
flex-wrap: wrap !important;
gap: 12px !important;
align-items: flex-start !important;
.o_attachment {
width: 80px !important;
height: 80px !important;
margin: 0 !important;
border-radius: 6px !important;
overflow: hidden !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1) !important;
transition: all 0.2s ease !important;
cursor: pointer !important;
position: relative !important;
border: 2px solid transparent !important;
&:hover {
transform: scale(1.05) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;
border-color: #0077b6 !important;
}
// Image preview thumbnail - clicking opens in new tab
.o_image {
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
}
// File icon for non-images
.o_attachment_icon {
width: 100% !important;
height: 100% !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
background: #e9ecef !important;
i {
font-size: 28px !important;
color: #6c757d !important;
}
}
// Hide filename inside thumbnail
.o_attachment_name {
display: none !important;
}
// Delete button styling
.o_attachment_delete {
position: absolute !important;
top: 2px !important;
right: 2px !important;
background: rgba(220, 53, 69, 0.9) !important;
color: white !important;
border-radius: 50% !important;
width: 18px !important;
height: 18px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
font-size: 10px !important;
opacity: 0 !important;
transition: opacity 0.2s !important;
z-index: 5 !important;
}
&:hover .o_attachment_delete {
opacity: 1 !important;
}
}
}
// Upload button - inline compact style
.o_attach,
button.o_attach,
.o_select_file_button {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 4px !important;
padding: 6px 12px !important;
border: 1px solid #28a745 !important;
border-radius: 4px !important;
background: white !important;
color: #28a745 !important;
font-size: 12px !important;
font-weight: 500 !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
height: auto !important;
width: auto !important;
min-height: 32px !important;
margin-top: 4px !important;
&:hover {
background: #28a745 !important;
color: white !important;
}
i, .fa {
font-size: 12px !important;
margin: 0 !important;
}
}
}
}
.fc-gallery-empty {
padding: 16px;
.fa {
color: #adb5bd;
}
}
}
// Google Places Autocomplete dropdown - ensure it appears above Odoo modals
.pac-container {
z-index: 100000 !important;
}

View File

@@ -0,0 +1,204 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2025 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
-->
<templates xml:space="preserve">
<!-- PDF Document Preview Dialog -->
<t t-name="fusion_claims.DocumentPreviewDialog">
<Dialog size="getDialogSize()" footer="false">
<t t-set-slot="header">
<div class="d-flex align-items-center justify-content-between w-100">
<div style="width: 50px"></div>
<h4 class="modal-title text-break fw-normal mb-0">
<i class="fa fa-file-pdf-o me-2 text-danger"/>
<t t-esc="props.title" />
</h4>
<div class="d-flex align-items-center gap-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
t-on-click="toggleMaximize"
t-att-title="state.isMaximized ? 'Exit Fullscreen' : 'Fullscreen'">
<i t-attf-class="fa {{ state.isMaximized ? 'fa-compress' : 'fa-expand' }}" />
</button>
<a t-att-href="getViewerUrl()"
target="_blank"
class="btn btn-sm btn-outline-primary"
title="Open in New Tab">
<i class="fa fa-external-link"/>
</a>
<button type="button" class="btn-close ms-2" t-on-click="props.close"/>
</div>
</div>
</t>
<div class="position-relative bg-secondary">
<!-- Loading spinner -->
<div t-if="state.isLoading"
class="position-absolute w-100 h-100 d-flex justify-content-center align-items-center bg-light"
style="z-index: 10; min-height: 400px;">
<div class="text-center">
<div class="spinner-border text-primary mb-3" role="status" style="width: 3rem; height: 3rem;">
<span class="visually-hidden">Loading...</span>
</div>
<p class="text-muted mb-0">Loading document...</p>
</div>
</div>
<!-- PDF.js viewer iframe - handles XFA/protected PDFs -->
<iframe t-att-src="getViewerUrl()"
class="border-0"
t-att-style="getFrameStyle()"
t-on-load="onIframeLoad"
allowfullscreen="true" />
</div>
</Dialog>
</t>
<!-- XML Viewer Dialog -->
<t t-name="fusion_claims.XMLViewerDialog">
<Dialog size="getDialogSize()" footer="false">
<t t-set-slot="header">
<div class="d-flex align-items-center justify-content-between w-100">
<div style="width: 50px"></div>
<h4 class="modal-title text-break fw-normal mb-0">
<i class="fa fa-file-code-o me-2 text-info"/>
<t t-esc="props.title" />
</h4>
<div class="d-flex align-items-center gap-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
t-on-click="copyToClipboard"
title="Copy to Clipboard">
<i class="fa fa-clipboard"/>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
t-on-click="toggleMaximize"
t-att-title="state.isMaximized ? 'Exit Fullscreen' : 'Fullscreen'">
<i t-attf-class="fa {{ state.isMaximized ? 'fa-compress' : 'fa-expand' }}" />
</button>
<button type="button" class="btn btn-sm btn-outline-primary"
t-on-click="downloadXml"
title="Download XML">
<i class="fa fa-download"/>
</button>
<button type="button" class="btn-close ms-2" t-on-click="props.close"/>
</div>
</div>
</t>
<div class="position-relative">
<!-- Loading spinner -->
<div t-if="state.isLoading"
class="d-flex justify-content-center align-items-center bg-light"
style="min-height: 400px;">
<div class="text-center">
<div class="spinner-border text-primary mb-3" role="status" style="width: 3rem; height: 3rem;">
<span class="visually-hidden">Loading...</span>
</div>
<p class="text-muted mb-0">Loading XML...</p>
</div>
</div>
<!-- Error message -->
<div t-if="state.error" class="alert alert-danger m-3">
<i class="fa fa-exclamation-triangle me-2"/>
<t t-esc="state.error"/>
</div>
<!-- XML content with syntax highlighting -->
<div t-if="!state.isLoading and !state.error"
class="xml-viewer-content"
t-att-style="state.isMaximized ? 'height: calc(98vh - 120px);' : 'height: calc(85vh - 120px);'">
<pre class="xml-code m-0 p-3"><code t-out="state.formattedXml"/></pre>
</div>
</div>
</Dialog>
</t>
<!-- Image Preview Dialog -->
<t t-name="fusion_claims.ImagePreviewDialog">
<Dialog size="'xl'" footer="false">
<t t-set-slot="header">
<div class="d-flex align-items-center justify-content-between w-100">
<div style="width: 100px">
<span t-if="hasMultiple" class="badge bg-secondary">
<t t-esc="currentPosition"/>
</span>
</div>
<h4 class="modal-title text-break fw-normal mb-0">
<i class="fa fa-image me-2 text-success"/>
<t t-esc="currentImage.name" />
</h4>
<div class="d-flex align-items-center gap-1">
<button type="button" class="btn btn-sm btn-outline-primary"
t-on-click="downloadImage"
title="Download Image">
<i class="fa fa-download"/>
</button>
<button type="button" class="btn-close ms-2" t-on-click="props.close"/>
</div>
</div>
</t>
<div class="position-relative d-flex align-items-center justify-content-center bg-dark"
style="min-height: 500px; max-height: 80vh;">
<!-- Loading spinner -->
<div t-if="state.isLoading"
class="position-absolute w-100 h-100 d-flex justify-content-center align-items-center"
style="z-index: 10;">
<div class="spinner-border text-light" role="status" style="width: 3rem; height: 3rem;">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<!-- Previous button -->
<button t-if="hasMultiple and state.currentIndex > 0"
type="button"
class="btn btn-dark btn-lg position-absolute start-0 ms-3"
style="z-index: 20; opacity: 0.7;"
t-on-click="previousImage">
<i class="fa fa-chevron-left fa-2x"/>
</button>
<!-- Image -->
<img t-att-src="imageUrl"
class="mw-100 mh-100"
style="object-fit: contain; max-height: 75vh;"
t-on-load="onImageLoad"
t-att-alt="currentImage.name"/>
<!-- Next button -->
<button t-if="hasMultiple and state.currentIndex &lt; props.images.length - 1"
type="button"
class="btn btn-dark btn-lg position-absolute end-0 me-3"
style="z-index: 20; opacity: 0.7;"
t-on-click="nextImage">
<i class="fa fa-chevron-right fa-2x"/>
</button>
</div>
<!-- Thumbnail strip for multiple images -->
<div t-if="hasMultiple" class="d-flex justify-content-center gap-2 p-3 bg-secondary">
<t t-foreach="props.images" t-as="img" t-key="img.id">
<div t-att-class="'border-2 rounded overflow-hidden cursor-pointer ' + (img_index === state.currentIndex ? 'border-primary' : 'border-transparent')"
style="width: 60px; height: 60px; cursor: pointer;"
t-on-click="() => { this.state.isLoading = true; this.state.currentIndex = img_index; }">
<img t-att-src="'/web/image/' + img.id + '?height=60'"
class="w-100 h-100"
style="object-fit: cover;"/>
</div>
</t>
</div>
</Dialog>
</t>
<!-- Preview Button Widget (no-save, client-side only) -->
<t t-name="fusion_claims.PreviewButtonWidget">
<button type="button"
class="btn btn-link p-0 border-0"
title="Preview"
t-on-click="onClick">
<i class="fa fa-eye"/>
</button>
</t>
</templates>

View File

@@ -0,0 +1,218 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_claims.FusionTaskMapView">
<div class="o_fusion_task_map_view">
<Layout display="display">
<t t-set-slot="control-panel-additional-actions">
<CogMenu/>
</t>
<t t-set-slot="layout-buttons">
<t t-call="{{ props.buttonTemplate }}"/>
</t>
<t t-set-slot="layout-actions">
<SearchBar toggler="searchBarToggler"/>
</t>
<t t-set-slot="control-panel-navigation-additional">
<t t-component="searchBarToggler.component" t-props="searchBarToggler.props"/>
</t>
<div class="fc_map_wrapper">
<!-- ========== SIDEBAR ========== -->
<div t-att-class="'fc_sidebar' + (state.sidebarOpen ? '' : ' fc_sidebar--collapsed')">
<!-- Sidebar header -->
<div class="fc_sidebar_header">
<div class="d-flex align-items-center justify-content-between">
<h6 class="mb-0 fw-bold">
<i class="fa fa-list-ul me-2"/>Deliveries
<span class="badge text-bg-primary ms-1" t-esc="state.taskCount"/>
</h6>
<button class="btn btn-sm btn-link text-muted p-0" t-on-click="toggleSidebar"
title="Toggle sidebar">
<i t-att-class="'fa ' + (state.sidebarOpen ? 'fa-chevron-left' : 'fa-chevron-right')"/>
</button>
</div>
<!-- New task button -->
<button class="btn btn-primary btn-sm w-100 mt-2" t-on-click="createNewTask">
<i class="fa fa-plus me-1"/>New Delivery Task
</button>
<!-- Day filter chips -->
<div class="fc_day_filters mt-2">
<t t-foreach="state.groups" t-as="group" t-key="group.key + '_filter'">
<button t-att-class="'fc_day_chip' + (isGroupVisible(group.key) ? ' fc_day_chip--active' : '')"
t-att-style="isGroupVisible(group.key) ? 'background:' + group.dayColor + ';color:#fff;border-color:' + group.dayColor : ''"
t-on-click="() => this.toggleDayFilter(group.key)">
<t t-esc="group.label"/>
<span class="fc_day_chip_count" t-esc="group.count"/>
</button>
</t>
<button class="fc_day_chip fc_day_chip--all" t-on-click="showAllDays"
title="Show all">All</button>
</div>
</div>
<!-- Sidebar body: grouped task list -->
<div class="fc_sidebar_body">
<t t-foreach="state.groups" t-as="group" t-key="group.key">
<!-- Group header (collapsible) with day color -->
<div class="fc_group_header" t-on-click="() => this.toggleGroup(group.key)">
<i t-att-class="'fa me-1 ' + (isGroupCollapsed(group.key) ? 'fa-caret-right' : 'fa-caret-down')"/>
<i class="fa fa-circle me-1" style="font-size:8px;"
t-att-style="'color:' + group.dayColor"/>
<span class="fc_group_label" t-esc="group.label"/>
<span t-if="!isGroupVisible(group.key)" class="fc_group_hidden_tag">hidden</span>
<span class="fc_group_badge" t-esc="group.count"/>
</div>
<!-- Group tasks -->
<div t-if="!isGroupCollapsed(group.key)" class="fc_group_tasks">
<t t-foreach="group.tasks" t-as="task" t-key="task.id">
<div t-att-class="'fc_task_card' + (state.activeTaskId === task.id ? ' fc_task_card--active' : '')"
t-on-click="() => this.focusTask(task.id)">
<!-- Card top row: number + status -->
<div class="fc_task_card_top">
<span class="fc_task_num" t-att-style="'background:' + task._dayColor">
<t t-esc="'#' + task._scheduleNum"/>
</span>
<span class="fc_task_status" t-att-style="'color:' + task._statusColor">
<i t-att-class="'fa ' + task._statusIcon" style="margin-right:3px;"/>
<t t-esc="task._statusLabel"/>
</span>
</div>
<!-- Client name -->
<div class="fc_task_client" t-esc="task._clientName"/>
<!-- Type + time -->
<div class="fc_task_meta">
<span><i class="fa fa-tag me-1"/><t t-esc="task._typeLbl"/></span>
<span><i class="fa fa-clock-o me-1"/><t t-esc="task._timeRange"/></span>
</div>
<!-- Technician + address -->
<div class="fc_task_detail">
<span><i class="fa fa-user me-1"/><t t-esc="task._techName"/></span>
</div>
<div t-if="task.address_display" class="fc_task_address">
<i class="fa fa-map-marker me-1"/>
<t t-esc="task.address_display"/>
</div>
<!-- Travel badge -->
<div t-if="task.travel_time_minutes" class="fc_task_travel">
<i class="fa fa-car me-1"/>
<t t-esc="task.travel_time_minutes"/> min travel
</div>
</div>
</t>
</div>
</t>
<!-- Empty state -->
<div t-if="state.groups.length === 0 and !state.loading" class="fc_sidebar_empty">
<i class="fa fa-inbox fa-2x text-muted d-block mb-2"/>
<span class="text-muted">No tasks found</span>
</div>
</div>
<!-- Sidebar footer: technician count -->
<div class="fc_sidebar_footer">
<div class="d-flex align-items-center gap-2">
<svg width="14" height="14" 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,sans-serif" font-weight="bold">T</text>
</svg>
<small class="text-muted">
<t t-esc="state.techCount"/> technician(s) online
</small>
</div>
</div>
</div>
<!-- Collapsed sidebar toggle -->
<button t-if="!state.sidebarOpen"
class="fc_sidebar_toggle_btn" t-on-click="toggleSidebar"
title="Open sidebar">
<i class="fa fa-chevron-right"/>
</button>
<!-- ========== MAP AREA ========== -->
<div class="fc_map_area">
<!-- Legend bar -->
<div class="fc_map_legend_bar d-flex align-items-center gap-3 px-3 py-2 border-bottom bg-view flex-wrap">
<button class="btn btn-sm d-flex align-items-center gap-1"
t-att-class="state.showTasks ? 'btn-primary' : 'btn-outline-secondary'"
t-on-click="toggleTasks">
<i class="fa fa-map-marker"/>Tasks
<span class="badge text-bg-secondary ms-1" t-esc="state.taskCount"/>
</button>
<button class="btn btn-sm d-flex align-items-center gap-1"
t-att-class="state.showTechnicians ? 'btn-primary' : 'btn-outline-secondary'"
t-on-click="toggleTechnicians">
<i class="fa fa-user"/>Techs
<span class="badge text-bg-secondary ms-1" t-esc="state.techCount"/>
</button>
<span class="border-start mx-1" style="height:20px;"/>
<span class="text-muted fw-bold" style="font-size:11px;">Pins:</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#ef4444;"/>Today</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#3b82f6;"/>Tomorrow</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#10b981;"/>This Week</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#a855f7;"/>Upcoming</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#9ca3af;"/>Yesterday</span>
<span class="flex-grow-1"/>
<button class="btn btn-sm d-flex align-items-center gap-1"
t-att-class="state.showTraffic ? 'btn-warning' : 'btn-outline-secondary'"
t-on-click="toggleTraffic" title="Toggle traffic layer">
<i class="fa fa-car"/>Traffic
</button>
<button class="btn btn-outline-secondary btn-sm" t-on-click="onRefresh" title="Refresh">
<i class="fa fa-refresh" t-att-class="{'fa-spin': state.loading}"/>
</button>
</div>
<!-- Map container -->
<div class="fc_map_container">
<div t-ref="mapContainer" style="position:absolute;top:0;left:0;right:0;bottom:0;"/>
<!-- Loading -->
<div t-if="state.loading"
class="position-absolute top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center"
style="z-index:10;background:rgba(255,255,255,.92);">
<div class="text-center">
<i class="fa fa-spinner fa-spin fa-3x text-primary mb-3 d-block"/>
<span class="text-muted">Loading Google Maps...</span>
</div>
</div>
<!-- Error -->
<div t-if="state.error"
class="position-absolute top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center"
style="z-index:10;background:rgba(255,255,255,.92);">
<div class="alert alert-danger m-4" role="alert">
<i class="fa fa-exclamation-triangle me-2"/><t t-esc="state.error"/>
</div>
</div>
<!-- Empty -->
<div t-if="!state.loading and !state.error and state.taskCount === 0 and state.techCount === 0"
class="position-absolute top-50 start-50 translate-middle text-center" style="z-index:5;">
<div class="bg-white rounded-3 shadow p-4">
<i class="fa fa-map-marker fa-3x text-muted mb-3 d-block"/>
<h5>No locations to show</h5>
<p class="text-muted mb-0">Try adjusting the filters or date range.</p>
</div>
</div>
</div>
</div>
</div>
</Layout>
</div>
</t>
<t t-name="fusion_claims.FusionTaskMapView.Buttons"/>
</templates>