Initial commit
This commit is contained in:
367
fusion_claims/static/src/css/fusion_task_map_view.scss
Normal file
367
fusion_claims/static/src/css/fusion_task_map_view.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Binary file not shown.
BIN
fusion_claims/static/src/pdf/sa_mobility_form_template.pdf
Normal file
BIN
fusion_claims/static/src/pdf/sa_mobility_form_template.pdf
Normal file
Binary file not shown.
BIN
fusion_claims/static/src/pdf/sa_mobility_page2_sample.pdf
Normal file
BIN
fusion_claims/static/src/pdf/sa_mobility_page2_sample.pdf
Normal file
Binary file not shown.
872
fusion_claims/static/src/scss/fusion_claims.scss
Normal file
872
fusion_claims/static/src/scss/fusion_claims.scss
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
204
fusion_claims/static/src/xml/document_preview.xml
Normal file
204
fusion_claims/static/src/xml/document_preview.xml
Normal 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 < 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>
|
||||
218
fusion_claims/static/src/xml/fusion_task_map_view.xml
Normal file
218
fusion_claims/static/src/xml/fusion_task_map_view.xml
Normal 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>
|
||||
Reference in New Issue
Block a user