1198 lines
48 KiB
JavaScript
1198 lines
48 KiB
JavaScript
/** @odoo-module **/
|
|
// Fusion Tasks - 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 = {
|
|
pending: "#f59e0b",
|
|
scheduled: "#3b82f6",
|
|
en_route: "#f59e0b",
|
|
in_progress: "#8b5cf6",
|
|
completed: "#10b981",
|
|
cancelled: "#ef4444",
|
|
rescheduled: "#f97316",
|
|
};
|
|
const STATUS_LABELS = {
|
|
pending: "Pending",
|
|
scheduled: "Scheduled",
|
|
en_route: "En Route",
|
|
in_progress: "In Progress",
|
|
completed: "Completed",
|
|
cancelled: "Cancelled",
|
|
rescheduled: "Rescheduled",
|
|
};
|
|
const STATUS_ICONS = {
|
|
pending: "fa-hourglass-half",
|
|
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_PENDING = "pending";
|
|
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_PENDING]: "Pending",
|
|
[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_PENDING]: "#f59e0b", // Amber
|
|
[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_PENDING]: "fa-hourglass-half",
|
|
[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 task into one of our group keys based on status and date */
|
|
function classifyTask(task) {
|
|
if (task.status === "pending") return GROUP_PENDING;
|
|
return classifyDate(task.scheduled_date);
|
|
}
|
|
|
|
function classifyDate(dateStr) {
|
|
if (!dateStr) return GROUP_PENDING;
|
|
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);
|
|
|
|
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;
|
|
return GROUP_LATER;
|
|
}
|
|
|
|
const SOURCE_COLORS = {
|
|
westin: "#0d6efd",
|
|
mobility: "#198754",
|
|
};
|
|
|
|
/** Extract unique technicians from task data, sorted by name */
|
|
function extractTechnicians(tasksData) {
|
|
const map = {};
|
|
for (const t of tasksData) {
|
|
if (t.technician_id) {
|
|
const [id, name] = t.technician_id;
|
|
if (!map[id]) {
|
|
map[id] = { id, name, initials: initialsOf(name) };
|
|
}
|
|
}
|
|
}
|
|
return Object.values(map).sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
/** Group + sort tasks, returning { groupKey: { label, tasks[], count } } */
|
|
function groupTasks(tasksData, localInstanceId, visibleTechIds) {
|
|
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 hasTechFilter = visibleTechIds && Object.keys(visibleTechIds).length > 0;
|
|
|
|
const groups = {};
|
|
const order = [GROUP_PENDING, 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,
|
|
};
|
|
}
|
|
|
|
const dayCounters = {};
|
|
for (const task of sorted) {
|
|
const techId = task.technician_id ? task.technician_id[0] : 0;
|
|
if (hasTechFilter && !visibleTechIds[techId]) continue;
|
|
|
|
const g = classifyTask(task);
|
|
const dayKey = task.scheduled_date || "none";
|
|
dayCounters[dayKey] = (dayCounters[dayKey] || 0) + 1;
|
|
task._scheduleNum = dayCounters[dayKey];
|
|
task._group = g;
|
|
task._dayColor = DAY_COLORS[g] || "#6b7280";
|
|
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.x_fc_sync_client_name || (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 || ""}`;
|
|
const src = task.x_fc_sync_source || localInstanceId || "";
|
|
task._sourceLabel = src ? src.charAt(0).toUpperCase() + src.slice(1) : "";
|
|
task._sourceColor = SOURCE_COLORS[src] || "#6c757d";
|
|
task._hasCoords = task.address_lat && task.address_lng && task.address_lat !== 0 && task.address_lng !== 0;
|
|
groups[g].tasks.push(task);
|
|
groups[g].count++;
|
|
}
|
|
|
|
return order.map((k) => groups[k]).filter((g) => g.count > 0);
|
|
}
|
|
|
|
|
|
// ── Controller ──────────────────────────────────────────────────────
|
|
export class FusionTaskMapController extends Component {
|
|
static template = "fusion_tasks.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,
|
|
showRoute: true,
|
|
taskCount: 0,
|
|
techCount: 0,
|
|
sidebarOpen: true,
|
|
groups: [],
|
|
collapsedGroups: {},
|
|
activeTaskId: null,
|
|
visibleGroups: {
|
|
[GROUP_YESTERDAY]: false,
|
|
[GROUP_TODAY]: true,
|
|
[GROUP_TOMORROW]: false,
|
|
[GROUP_THIS_WEEK]: false,
|
|
[GROUP_LATER]: false,
|
|
},
|
|
allTechnicians: [],
|
|
visibleTechIds: {},
|
|
});
|
|
|
|
// 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.routeLines = []; // route polylines
|
|
this.routeLabels = []; // travel time overlay labels
|
|
this.routeAnimFrameId = null;
|
|
this.infoWindow = null;
|
|
this.techStartLocations = {};
|
|
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();
|
|
this._clearRoute();
|
|
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 ─────────────────────────────────────────────────────────
|
|
_storeResult(result) {
|
|
this.localInstanceId = result.local_instance_id || this.localInstanceId || "";
|
|
this.tasksData = result.tasks || [];
|
|
this.locationsData = result.locations || [];
|
|
this.techStartLocations = result.tech_start_locations || {};
|
|
this.state.allTechnicians = extractTechnicians(this.tasksData);
|
|
this._rebuildGroups();
|
|
}
|
|
|
|
_rebuildGroups() {
|
|
this.state.groups = groupTasks(
|
|
this.tasksData, this.localInstanceId, this.state.visibleTechIds,
|
|
);
|
|
const filteredCount = this.state.groups.reduce((s, g) => s + g.count, 0);
|
|
this.state.taskCount = filteredCount;
|
|
this.state.techCount = this.locationsData.length;
|
|
}
|
|
|
|
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._storeResult(result);
|
|
|
|
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.map) {
|
|
this._renderMarkers();
|
|
} else 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 _softRefresh() {
|
|
if (!this.map) return;
|
|
try {
|
|
const center = this.map.getCenter();
|
|
const zoom = this.map.getZoom();
|
|
|
|
const domain = this._getDomain();
|
|
const result = await this.orm.call("fusion.technician.task", "get_map_data", [domain]);
|
|
this._storeResult(result);
|
|
|
|
this._placeMarkers();
|
|
|
|
if (center && zoom != null) {
|
|
this.map.setCenter(center);
|
|
this.map.setZoom(zoom);
|
|
}
|
|
} catch (e) {
|
|
console.error("FusionTaskMap soft refresh error:", e);
|
|
}
|
|
}
|
|
|
|
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._storeResult(result);
|
|
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 = [];
|
|
}
|
|
|
|
_clearRoute() {
|
|
if (this.routeAnimFrameId) {
|
|
cancelAnimationFrame(this.routeAnimFrameId);
|
|
this.routeAnimFrameId = null;
|
|
}
|
|
for (const l of this.routeLines) l.setMap(null);
|
|
this.routeLines = [];
|
|
for (const lb of this.routeLabels) lb.setMap(null);
|
|
this.routeLabels = [];
|
|
}
|
|
|
|
_placeMarkers() {
|
|
for (const m of this.taskMarkers) m.setMap(null);
|
|
for (const m of this.techMarkers) m.setMap(null);
|
|
this.taskMarkers = [];
|
|
this.taskMarkerMap = {};
|
|
this.techMarkers = [];
|
|
|
|
const bounds = new google.maps.LatLngBounds();
|
|
let hasBounds = false;
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 src = loc.sync_instance || this.localInstanceId || "";
|
|
const isRemote = src && src !== this.localInstanceId;
|
|
const pinColor = isRemote
|
|
? (SOURCE_COLORS[src] || "#6c757d")
|
|
: "#1d4ed8";
|
|
const srcLabel = src ? src.charAt(0).toUpperCase() + src.slice(1) : "";
|
|
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="${pinColor}" 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 + (isRemote ? ` [${srcLabel}]` : ""),
|
|
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:${pinColor};color:#fff;padding:10px 14px;">
|
|
<strong><i class="fa fa-user" style="margin-right:6px;"></i>${loc.name}</strong>
|
|
${srcLabel ? `<span style="float:right;font-size:10px;font-weight:600;background:rgba(255,255,255,.2);padding:2px 8px;border-radius:8px;">${srcLabel}</span>` : ""}
|
|
</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;
|
|
}
|
|
}
|
|
|
|
const starts = this.techStartLocations || {};
|
|
for (const uid of Object.keys(starts)) {
|
|
const sl = starts[uid];
|
|
if (sl && sl.lat && sl.lng) {
|
|
bounds.extend({ lat: sl.lat, lng: sl.lng });
|
|
hasBounds = true;
|
|
}
|
|
}
|
|
|
|
return { bounds, hasBounds };
|
|
}
|
|
|
|
_renderMarkers() {
|
|
this._clearRoute();
|
|
const { bounds, hasBounds } = this._placeMarkers();
|
|
|
|
if (this.state.showRoute && this.state.showTasks) {
|
|
this._renderRoute();
|
|
}
|
|
|
|
if (hasBounds) {
|
|
try {
|
|
this.map.fitBounds(bounds);
|
|
if (this.taskMarkers.length + this.techMarkers.length === 1) {
|
|
this.map.setZoom(14);
|
|
}
|
|
} catch (_e) {
|
|
// bounds not ready yet
|
|
}
|
|
}
|
|
}
|
|
|
|
_renderRoute() {
|
|
this._clearRoute();
|
|
|
|
const routeSegments = {};
|
|
for (const group of this.state.groups) {
|
|
if (this.state.visibleGroups[group.key] === false) continue;
|
|
for (const task of group.tasks) {
|
|
if (!task._hasCoords) continue;
|
|
const techId = task.technician_id ? task.technician_id[0] : 0;
|
|
if (!techId) continue;
|
|
const dayKey = task.scheduled_date || "none";
|
|
const segKey = `${techId}_${dayKey}`;
|
|
if (!routeSegments[segKey]) {
|
|
routeSegments[segKey] = {
|
|
name: task._techName, day: dayKey,
|
|
techId, tasks: [],
|
|
};
|
|
}
|
|
routeSegments[segKey].tasks.push(task);
|
|
}
|
|
}
|
|
|
|
const LEG_COLORS = [
|
|
"#3b82f6", "#f59e0b", "#8b5cf6", "#ec4899",
|
|
"#f97316", "#0ea5e9", "#d946ef", "#06b6d4",
|
|
"#a855f7", "#6366f1", "#eab308", "#0284c7",
|
|
"#c026d3", "#7c3aed", "#2563eb", "#db2777",
|
|
"#9333ea", "#0891b2", "#4f46e5", "#be185d",
|
|
];
|
|
let globalLegIdx = 0;
|
|
|
|
if (!this._directionsService) {
|
|
this._directionsService = new google.maps.DirectionsService();
|
|
}
|
|
|
|
const allAnimLines = [];
|
|
const starts = this.techStartLocations || {};
|
|
|
|
for (const segKey of Object.keys(routeSegments)) {
|
|
const seg = routeSegments[segKey];
|
|
const tasks = seg.tasks;
|
|
tasks.sort((a, b) => (a.time_start || 0) - (b.time_start || 0));
|
|
|
|
const startLoc = starts[seg.techId];
|
|
const hasStart = startLoc && startLoc.lat && startLoc.lng;
|
|
|
|
if (tasks.length < 2 && !hasStart) continue;
|
|
if (tasks.length < 1) continue;
|
|
|
|
const segBaseColor = LEG_COLORS[globalLegIdx % LEG_COLORS.length];
|
|
|
|
let origin, destination, waypoints, hasStartLeg;
|
|
|
|
if (hasStart) {
|
|
origin = { lat: startLoc.lat, lng: startLoc.lng };
|
|
destination = {
|
|
lat: tasks[tasks.length - 1].address_lat,
|
|
lng: tasks[tasks.length - 1].address_lng,
|
|
};
|
|
waypoints = tasks.slice(0, -1).map(t => ({
|
|
location: { lat: t.address_lat, lng: t.address_lng },
|
|
stopover: true,
|
|
}));
|
|
hasStartLeg = true;
|
|
} else {
|
|
origin = { lat: tasks[0].address_lat, lng: tasks[0].address_lng };
|
|
destination = {
|
|
lat: tasks[tasks.length - 1].address_lat,
|
|
lng: tasks[tasks.length - 1].address_lng,
|
|
};
|
|
waypoints = tasks.slice(1, -1).map(t => ({
|
|
location: { lat: t.address_lat, lng: t.address_lng },
|
|
stopover: true,
|
|
}));
|
|
hasStartLeg = false;
|
|
}
|
|
|
|
if (hasStart) {
|
|
const startSvg =
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36">` +
|
|
`<circle cx="18" cy="18" r="16" fill="${segBaseColor}" stroke="#fff" stroke-width="3"/>` +
|
|
`<text x="18" y="23" text-anchor="middle" fill="#fff" font-size="16" font-family="Arial,sans-serif">⌂</text>` +
|
|
`</svg>`;
|
|
const startMarker = new google.maps.Marker({
|
|
position: origin,
|
|
map: this.map,
|
|
title: `${seg.name} - Start`,
|
|
icon: {
|
|
url: "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(startSvg),
|
|
scaledSize: new google.maps.Size(32, 32),
|
|
anchor: new google.maps.Point(16, 16),
|
|
},
|
|
zIndex: 5,
|
|
});
|
|
startMarker.addListener("click", () => {
|
|
this.infoWindow.setContent(`
|
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:180px;">
|
|
<div style="background:${segBaseColor};color:#fff;padding:8px 12px;border-radius:6px 6px 0 0;">
|
|
<strong>${seg.name} - Start</strong>
|
|
</div>
|
|
<div style="padding:8px 12px;font-size:13px;">
|
|
${startLoc.address || 'Start location'}
|
|
<div style="color:#6b7280;margin-top:4px;font-size:11px;">${startLoc.source === 'clock_in' ? 'Clock-in location' : startLoc.source === 'start_address' ? 'Home address' : 'Company HQ'}</div>
|
|
</div>
|
|
</div>`);
|
|
this.infoWindow.open(this.map, startMarker);
|
|
});
|
|
this.routeLines.push(startMarker);
|
|
}
|
|
|
|
this._directionsService.route({
|
|
origin,
|
|
destination,
|
|
waypoints,
|
|
optimizeWaypoints: false,
|
|
travelMode: google.maps.TravelMode.DRIVING,
|
|
avoidTolls: true,
|
|
drivingOptions: {
|
|
departureTime: new Date(),
|
|
trafficModel: "bestguess",
|
|
},
|
|
}, (result, status) => {
|
|
if (status !== "OK" || !result.routes || !result.routes[0]) return;
|
|
|
|
const route = result.routes[0];
|
|
|
|
for (let li = 0; li < route.legs.length; li++) {
|
|
const leg = route.legs[li];
|
|
const legColor = LEG_COLORS[globalLegIdx % LEG_COLORS.length];
|
|
globalLegIdx++;
|
|
|
|
const legPath = [];
|
|
for (const step of leg.steps) {
|
|
for (const pt of step.path) legPath.push(pt);
|
|
}
|
|
if (legPath.length < 2) continue;
|
|
|
|
const baseLine = new google.maps.Polyline({
|
|
path: legPath, map: this.map,
|
|
strokeColor: legColor, strokeOpacity: 0.25, strokeWeight: 6,
|
|
zIndex: 1,
|
|
});
|
|
this.routeLines.push(baseLine);
|
|
|
|
const animLine = new google.maps.Polyline({
|
|
path: legPath, map: this.map,
|
|
strokeOpacity: 0, strokeWeight: 0, zIndex: 2,
|
|
icons: [{
|
|
icon: {
|
|
path: "M 0,-0.5 0,0.5",
|
|
strokeOpacity: 0.8, strokeColor: legColor,
|
|
strokeWeight: 3, scale: 4,
|
|
},
|
|
offset: "0%", repeat: "16px",
|
|
}],
|
|
});
|
|
this.routeLines.push(animLine);
|
|
allAnimLines.push(animLine);
|
|
|
|
const arrowLine = new google.maps.Polyline({
|
|
path: legPath, map: this.map,
|
|
strokeOpacity: 0, strokeWeight: 0, zIndex: 3,
|
|
icons: [{
|
|
icon: {
|
|
path: google.maps.SymbolPath.FORWARD_OPEN_ARROW,
|
|
scale: 3, strokeColor: legColor,
|
|
strokeOpacity: 0.9, strokeWeight: 2.5,
|
|
},
|
|
offset: "0%", repeat: "80px",
|
|
}],
|
|
});
|
|
this.routeLines.push(arrowLine);
|
|
allAnimLines.push(arrowLine);
|
|
|
|
const dur = leg.duration_in_traffic || leg.duration;
|
|
const dist = leg.distance;
|
|
if (dur) {
|
|
const totalMins = Math.round(dur.value / 60);
|
|
const totalKm = dist ? (dist.value / 1000).toFixed(1) : null;
|
|
|
|
const destIdx = hasStartLeg ? li : li + 1;
|
|
const destTask = destIdx < tasks.length ? tasks[destIdx] : tasks[tasks.length - 1];
|
|
const etaFloat = destTask.time_start || 0;
|
|
const etaStr = etaFloat ? floatToTime12(etaFloat) : "";
|
|
|
|
const techName = seg.name;
|
|
this.routeLabels.push(this._createTravelLabel(
|
|
legPath, totalMins, totalKm, legColor, techName, etaStr,
|
|
));
|
|
}
|
|
}
|
|
|
|
if (!this.routeAnimFrameId) {
|
|
this._startRouteAnimation(allAnimLines);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
_pointAlongLeg(leg, fraction) {
|
|
const points = [];
|
|
for (const step of leg.steps) {
|
|
for (const pt of step.path) {
|
|
points.push(pt);
|
|
}
|
|
}
|
|
if (points.length < 2) return leg.start_location;
|
|
|
|
const segDists = [];
|
|
let totalDist = 0;
|
|
for (let i = 1; i < points.length; i++) {
|
|
const d = google.maps.geometry
|
|
? google.maps.geometry.spherical.computeDistanceBetween(points[i - 1], points[i])
|
|
: this._haversine(points[i - 1], points[i]);
|
|
segDists.push(d);
|
|
totalDist += d;
|
|
}
|
|
|
|
const target = totalDist * fraction;
|
|
let acc = 0;
|
|
for (let i = 0; i < segDists.length; i++) {
|
|
if (acc + segDists[i] >= target) {
|
|
const remain = target - acc;
|
|
const ratio = segDists[i] > 0 ? remain / segDists[i] : 0;
|
|
return new google.maps.LatLng(
|
|
points[i].lat() + (points[i + 1].lat() - points[i].lat()) * ratio,
|
|
points[i].lng() + (points[i + 1].lng() - points[i].lng()) * ratio,
|
|
);
|
|
}
|
|
acc += segDists[i];
|
|
}
|
|
return points[points.length - 1];
|
|
}
|
|
|
|
_haversine(a, b) {
|
|
const R = 6371000;
|
|
const dLat = (b.lat() - a.lat()) * Math.PI / 180;
|
|
const dLng = (b.lng() - a.lng()) * Math.PI / 180;
|
|
const s = Math.sin(dLat / 2) ** 2 +
|
|
Math.cos(a.lat() * Math.PI / 180) * Math.cos(b.lat() * Math.PI / 180) *
|
|
Math.sin(dLng / 2) ** 2;
|
|
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
|
|
}
|
|
|
|
_createTravelLabel(legPath, mins, km, color, techName, eta) {
|
|
if (!this._TravelLabel) {
|
|
this._TravelLabel = class extends google.maps.OverlayView {
|
|
constructor(path, html) {
|
|
super();
|
|
this._path = path;
|
|
this._html = html;
|
|
this._div = null;
|
|
}
|
|
onAdd() {
|
|
this._div = document.createElement("div");
|
|
this._div.style.position = "absolute";
|
|
this._div.style.whiteSpace = "nowrap";
|
|
this._div.style.pointerEvents = "none";
|
|
this._div.style.zIndex = "50";
|
|
this._div.style.transition = "left .3s ease, top .3s ease";
|
|
this._div.innerHTML = this._html;
|
|
this.getPanes().floatPane.appendChild(this._div);
|
|
}
|
|
draw() {
|
|
const proj = this.getProjection();
|
|
if (!proj || !this._div) return;
|
|
const map = this.getMap();
|
|
if (!map) return;
|
|
const bounds = map.getBounds();
|
|
if (!bounds) return;
|
|
|
|
const visible = this._path.filter(p => bounds.contains(p));
|
|
if (visible.length === 0) {
|
|
this._div.style.display = "none";
|
|
return;
|
|
}
|
|
this._div.style.display = "";
|
|
|
|
const anchor = visible[Math.floor(visible.length / 2)];
|
|
|
|
const px = proj.fromLatLngToDivPixel(anchor);
|
|
if (px) {
|
|
this._div.style.left = (px.x - this._div.offsetWidth / 2) + "px";
|
|
this._div.style.top = (px.y - this._div.offsetHeight - 8) + "px";
|
|
}
|
|
}
|
|
onRemove() {
|
|
if (this._div && this._div.parentNode) {
|
|
this._div.parentNode.removeChild(this._div);
|
|
}
|
|
this._div = null;
|
|
}
|
|
};
|
|
}
|
|
|
|
const timeStr = mins < 60
|
|
? `${mins} min`
|
|
: `${Math.floor(mins / 60)}h ${mins % 60}m`;
|
|
const distStr = km ? `${km} km` : "";
|
|
|
|
const firstName = techName ? techName.split(" ")[0] : "";
|
|
const html = `<div style="
|
|
display:inline-flex;align-items:center;gap:5px;
|
|
background:#fff;border:2px solid ${color};
|
|
border-radius:16px;padding:3px 10px;
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
|
font-size:11px;font-weight:700;color:#1f2937;
|
|
box-shadow:0 2px 8px rgba(0,0,0,.18);
|
|
">${firstName ? `<span style="color:${color};font-weight:600;">${firstName}</span><span style="color:#d1d5db;">|</span>` : ""}<span style="color:${color};">🚗</span><span>${timeStr}</span>${distStr ? `<span style="color:#9ca3af;font-weight:500;">· ${distStr}</span>` : ""}${eta ? `<span style="color:#d1d5db;">|</span><span style="color:#059669;font-weight:700;">ETA ${eta}</span>` : ""}</div>`;
|
|
|
|
const label = new this._TravelLabel(legPath, html);
|
|
label.setMap(this.map);
|
|
return label;
|
|
}
|
|
|
|
_startRouteAnimation(animLines) {
|
|
let off = 0;
|
|
let last = 0;
|
|
const animate = (ts) => {
|
|
this.routeAnimFrameId = requestAnimationFrame(animate);
|
|
if (ts - last < 50) return;
|
|
last = ts;
|
|
off = (off + 0.08) % 100;
|
|
const pct = off + "%";
|
|
for (const line of animLines) {
|
|
const icons = line.get("icons");
|
|
if (icons && icons.length > 0) {
|
|
icons[0].offset = pct;
|
|
line.set("icons", icons);
|
|
}
|
|
}
|
|
};
|
|
this.routeAnimFrameId = requestAnimationFrame(animate);
|
|
}
|
|
|
|
_openTaskPopup(task, marker) {
|
|
const c = task._dayColor;
|
|
const sc = task._statusColor;
|
|
const navDest = task.address_lat && task.address_lng
|
|
? `${task.address_lat},${task.address_lng}`
|
|
: encodeURIComponent(task.address_display || "");
|
|
const html = `
|
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:290px;max-width:360px;color:#1f2937;">
|
|
<div style="background:${c};padding:14px 16px 12px;border-radius:0;">
|
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px;">
|
|
<span style="color:rgba(255,255,255,.75);font-size:11px;font-weight:600;letter-spacing:.3px;">#${task._scheduleNum} ${task.name}</span>
|
|
<span style="font-size:10px;font-weight:600;background:${sc};color:#fff;padding:2px 10px;border-radius:10px;">${task._statusLabel}</span>
|
|
</div>
|
|
<div style="color:#fff;font-size:16px;font-weight:700;line-height:1.25;">${task._clientName}</div>
|
|
</div>
|
|
<div style="padding:10px 16px 6px;display:flex;gap:6px;flex-wrap:wrap;">
|
|
<span style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;background:#f1f5f9;color:#334155;padding:3px 10px;border-radius:4px;">
|
|
<span style="opacity:.5;"></span>${task._typeLbl}
|
|
</span>
|
|
<span style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;background:#f1f5f9;color:#334155;padding:3px 10px;border-radius:4px;">
|
|
<span style="opacity:.5;"></span>${task._timeRange}
|
|
</span>
|
|
${task.travel_time_minutes ? `<span style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;background:#f1f5f9;color:#334155;padding:3px 10px;border-radius:4px;"><span style="opacity:.5;"></span>${task.travel_time_minutes} min</span>` : ""}
|
|
</div>
|
|
<div style="padding:8px 16px 12px;font-size:12px;line-height:1.7;color:#374151;">
|
|
<div style="display:flex;align-items:center;gap:6px;"><span style="color:#9ca3af;width:14px;text-align:center;">👤</span><span>${task._techName}</span></div>
|
|
<div style="display:flex;align-items:center;gap:6px;"><span style="color:#9ca3af;width:14px;text-align:center;">📅</span><span>${task.scheduled_date || "No date"}</span></div>
|
|
${task.address_display ? `<div style="display:flex;align-items:flex-start;gap:6px;"><span style="color:#9ca3af;width:14px;text-align:center;flex-shrink:0;">📍</span><span>${task.address_display}</span></div>` : ""}
|
|
</div>
|
|
<div style="padding:6px 16px 14px;display:flex;gap:8px;align-items:center;">
|
|
<button onclick="window.__fusionMapOpenTask(${task.id})"
|
|
style="background:${c};color:#fff;border:none;padding:7px 20px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;transition:opacity .15s;">
|
|
Open Task
|
|
</button>
|
|
<a href="https://www.google.com/maps/dir/?api=1&destination=${navDest}"
|
|
target="_blank" style="color:${c};text-decoration:none;font-size:12px;font-weight:600;padding:7px 4px;">
|
|
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();
|
|
}
|
|
|
|
// ── Technician filter ─────────────────────────────────────────────
|
|
toggleTechFilter(techId) {
|
|
if (this.state.visibleTechIds[techId]) {
|
|
delete this.state.visibleTechIds[techId];
|
|
} else {
|
|
this.state.visibleTechIds[techId] = true;
|
|
}
|
|
this._rebuildGroups();
|
|
this._renderMarkers();
|
|
}
|
|
|
|
isTechVisible(techId) {
|
|
const hasFilter = Object.keys(this.state.visibleTechIds).length > 0;
|
|
return !hasFilter || !!this.state.visibleTechIds[techId];
|
|
}
|
|
|
|
showAllTechs() {
|
|
this.state.visibleTechIds = {};
|
|
this._rebuildGroups();
|
|
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();
|
|
}
|
|
toggleRoute() {
|
|
this.state.showRoute = !this.state.showRoute;
|
|
if (this.state.showRoute) {
|
|
this._renderRoute();
|
|
} else {
|
|
this._clearRoute();
|
|
}
|
|
}
|
|
onRefresh() {
|
|
this.state.loading = true;
|
|
this._loadAndRender();
|
|
}
|
|
async openTask(taskId) {
|
|
if (!taskId) return;
|
|
try {
|
|
await this.actionService.doAction(
|
|
{
|
|
type: "ir.actions.act_window",
|
|
res_model: "fusion.technician.task",
|
|
res_id: taskId,
|
|
view_mode: "form",
|
|
views: [[false, "form"]],
|
|
target: "new",
|
|
context: { dialog_size: "extra-large" },
|
|
},
|
|
{ onClose: () => this._softRefresh() },
|
|
);
|
|
} catch (e) {
|
|
console.error("[FusionMap] openTask failed:", e);
|
|
this.actionService.doAction({
|
|
type: "ir.actions.act_window",
|
|
res_model: "fusion.technician.task",
|
|
res_id: taskId,
|
|
view_mode: "form",
|
|
views: [[false, "form"]],
|
|
target: "current",
|
|
});
|
|
}
|
|
}
|
|
async createNewTask() {
|
|
try {
|
|
await this.actionService.doAction(
|
|
{
|
|
type: "ir.actions.act_window",
|
|
res_model: "fusion.technician.task",
|
|
view_mode: "form",
|
|
views: [[false, "form"]],
|
|
target: "new",
|
|
context: { default_task_type: "delivery", dialog_size: "extra-large" },
|
|
},
|
|
{ onClose: () => this._softRefresh() },
|
|
);
|
|
} catch (e) {
|
|
console.error("[FusionMap] createNewTask failed:", e);
|
|
this.actionService.doAction({
|
|
type: "ir.actions.act_window",
|
|
res_model: "fusion.technician.task",
|
|
view_mode: "form",
|
|
views: [[false, "form"]],
|
|
target: "current",
|
|
context: { default_task_type: "delivery" },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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_tasks.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_tasks.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);
|