/** @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 ( `` + `` + `` + `#${txt}` + `` ); } 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 = `` + `` + `${initials}` + ``; 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(`
${loc.name} ${srcLabel ? `${srcLabel}` : ""}
Last seen: ${loc.logged_at || "Unknown"}
Accuracy: ${loc.accuracy ? Math.round(loc.accuracy) + "m" : "N/A"}
`); 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 = `` + `` + `` + ``; 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(`
${seg.name} - Start
${startLoc.address || 'Start location'}
${startLoc.source === 'clock_in' ? 'Clock-in location' : startLoc.source === 'start_address' ? 'Home address' : 'Company HQ'}
`); 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 = `
${firstName ? `${firstName}|` : ""}🚗${timeStr}${distStr ? `· ${distStr}` : ""}${eta ? `|ETA ${eta}` : ""}
`; 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 = `
#${task._scheduleNum} ${task.name} ${task._statusLabel}
${task._clientName}
${task._typeLbl} ${task._timeRange} ${task.travel_time_minutes ? `${task.travel_time_minutes} min` : ""}
👤${task._techName}
📅${task.scheduled_date || "No date"}
${task.address_display ? `
📍${task.address_display}
` : ""}
Navigate →
`; 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 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);