changes
This commit is contained in:
@@ -320,6 +320,25 @@ $transition-speed: .25s;
|
||||
.fa { opacity: .8; }
|
||||
}
|
||||
|
||||
.fc_task_edit_btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--btn-primary-color, #fff);
|
||||
background: var(--btn-primary-bg, #{$primary});
|
||||
padding: 2px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
transition: all .15s;
|
||||
|
||||
&:hover {
|
||||
opacity: .85;
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Map area ────────────────────────────────────────────────────────
|
||||
.fc_map_area {
|
||||
flex: 1 1 auto;
|
||||
@@ -341,15 +360,21 @@ $transition-speed: .25s;
|
||||
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.
|
||||
// ── Google Maps InfoWindow override ──────────────────────────────────
|
||||
.gm-style-iw-d {
|
||||
overflow: auto !important;
|
||||
}
|
||||
.gm-style .gm-style-iw-c {
|
||||
padding: 0 !important;
|
||||
border-radius: 10px !important;
|
||||
overflow: hidden !important;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,.15) !important;
|
||||
}
|
||||
.gm-style .gm-style-iw-tc {
|
||||
display: none !important;
|
||||
}
|
||||
.gm-style .gm-ui-hover-effect {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// ── Responsive ──────────────────────────────────────────────────────
|
||||
|
||||
20
fusion_claims/static/src/js/debug_required_fields.js
Normal file
20
fusion_claims/static/src/js/debug_required_fields.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Record } from "@web/model/relational_model/record";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(Record.prototype, {
|
||||
_displayInvalidFieldNotification() {
|
||||
const fieldNames = [];
|
||||
for (const fieldName of this._invalidFields) {
|
||||
const fieldDef = this.fields[fieldName];
|
||||
const label = fieldDef?.string || fieldName;
|
||||
fieldNames.push(`${label} (${fieldName})`);
|
||||
}
|
||||
const message = fieldNames.length
|
||||
? `Missing required fields:\n${fieldNames.join(", ")}`
|
||||
: "Missing required fields (unknown)";
|
||||
console.error("FUSION DEBUG:", message, Array.from(this._invalidFields));
|
||||
return this.model.notification.add(message, { type: "danger" });
|
||||
},
|
||||
});
|
||||
@@ -203,11 +203,12 @@ function groupTasks(tasksData, localInstanceId) {
|
||||
};
|
||||
}
|
||||
|
||||
let globalIdx = 0;
|
||||
const dayCounters = {};
|
||||
for (const task of sorted) {
|
||||
globalIdx++;
|
||||
const g = classifyTask(task);
|
||||
task._scheduleNum = globalIdx;
|
||||
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"; // Pin colour by day
|
||||
task._statusColor = STATUS_COLORS[task.status] || "#6b7280";
|
||||
@@ -255,6 +256,7 @@ export class FusionTaskMapController extends Component {
|
||||
showTasks: true,
|
||||
showTechnicians: true,
|
||||
showTraffic: true,
|
||||
showRoute: true,
|
||||
taskCount: 0,
|
||||
techCount: 0,
|
||||
// Sidebar
|
||||
@@ -264,11 +266,11 @@ export class FusionTaskMapController extends Component {
|
||||
activeTaskId: null, // Highlighted task
|
||||
// Day filters for map pins (which groups show on map)
|
||||
visibleGroups: {
|
||||
[GROUP_YESTERDAY]: false, // hidden by default
|
||||
[GROUP_YESTERDAY]: false,
|
||||
[GROUP_TODAY]: true,
|
||||
[GROUP_TOMORROW]: true,
|
||||
[GROUP_THIS_WEEK]: false, // hidden by default
|
||||
[GROUP_LATER]: false, // hidden by default
|
||||
[GROUP_TOMORROW]: false,
|
||||
[GROUP_THIS_WEEK]: false,
|
||||
[GROUP_LATER]: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -280,7 +282,11 @@ export class FusionTaskMapController extends Component {
|
||||
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 = [];
|
||||
@@ -312,6 +318,7 @@ export class FusionTaskMapController extends Component {
|
||||
});
|
||||
onWillUnmount(() => {
|
||||
this._clearMarkers();
|
||||
this._clearRoute();
|
||||
window.__fusionMapOpenTask = () => {};
|
||||
});
|
||||
}
|
||||
@@ -327,17 +334,22 @@ export class FusionTaskMapController extends Component {
|
||||
}
|
||||
|
||||
// ── 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.taskCount = this.tasksData.length;
|
||||
this.state.techCount = this.locationsData.length;
|
||||
this.state.groups = groupTasks(this.tasksData, this.localInstanceId);
|
||||
}
|
||||
|
||||
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.localInstanceId = result.local_instance_id || "";
|
||||
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.localInstanceId);
|
||||
this._storeResult(result);
|
||||
|
||||
if (!this.apiKey) {
|
||||
this.state.error = _t("Google Maps API key not configured. Go to Settings > Fusion Claims.");
|
||||
@@ -345,7 +357,11 @@ export class FusionTaskMapController extends Component {
|
||||
return;
|
||||
}
|
||||
await loadGoogleMaps(this.apiKey);
|
||||
if (this.mapRef.el) this._initMap();
|
||||
if (this.map) {
|
||||
this._renderMarkers();
|
||||
} else if (this.mapRef.el) {
|
||||
this._initMap();
|
||||
}
|
||||
this.state.loading = false;
|
||||
} catch (e) {
|
||||
console.error("FusionTaskMap load error:", e);
|
||||
@@ -354,17 +370,33 @@ export class FusionTaskMapController extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
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.localInstanceId = result.local_instance_id || this.localInstanceId || "";
|
||||
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.localInstanceId);
|
||||
this._storeResult(result);
|
||||
this._renderMarkers();
|
||||
} catch (e) {
|
||||
console.error("FusionTaskMap update error:", e);
|
||||
@@ -407,12 +439,27 @@ export class FusionTaskMapController extends Component {
|
||||
this.techMarkers = [];
|
||||
}
|
||||
|
||||
_renderMarkers() {
|
||||
this._clearMarkers();
|
||||
_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;
|
||||
|
||||
// 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;
|
||||
@@ -444,7 +491,6 @@ export class FusionTaskMapController extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
// Technician markers
|
||||
if (this.state.showTechnicians) {
|
||||
for (const loc of this.locationsData) {
|
||||
if (!loc.latitude || !loc.longitude) continue;
|
||||
@@ -485,45 +531,410 @@ export class FusionTaskMapController extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
this.map.fitBounds(bounds);
|
||||
if (this.taskMarkers.length + this.techMarkers.length === 1) {
|
||||
this.map.setZoom(14);
|
||||
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: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 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: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 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 14px 12px;border-top:1px solid #e5e7eb;display:flex;gap:10px;">
|
||||
<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:6px 16px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600;">
|
||||
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=${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 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>`;
|
||||
@@ -605,26 +1016,69 @@ export class FusionTaskMapController extends Component {
|
||||
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();
|
||||
}
|
||||
openTask(taskId) {
|
||||
this.actionService.switchView("form", { resId: taskId });
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
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();
|
||||
},
|
||||
});
|
||||
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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,11 @@
|
||||
<i class="fa fa-building-o me-1"/>
|
||||
<t t-esc="task._sourceLabel"/>
|
||||
</span>
|
||||
<span class="fc_task_edit_btn"
|
||||
t-on-click.stop="() => this.openTask(task.id)"
|
||||
title="Edit task">
|
||||
<i class="fa fa-pencil me-1"/>Edit
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
@@ -170,6 +175,11 @@
|
||||
<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.showRoute ? 'btn-info' : 'btn-outline-secondary'"
|
||||
t-on-click="toggleRoute" title="Toggle route animation">
|
||||
<i class="fa fa-road"/>Route
|
||||
</button>
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user