/** @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 ( `` ); } 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 = ``; 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(`