feat: separate fusion field service and LTC into standalone modules, update core modules

- fusion_claims: separated field service logic, updated controllers/views
- fusion_tasks: updated task views and map integration
- fusion_authorizer_portal: added page 11 signing, schedule booking, migrations
- fusion_shipping: new standalone shipping module (Canada Post, FedEx, DHL, Purolator)
- fusion_ltc_management: new standalone LTC management module
This commit is contained in:
2026-03-11 16:19:52 +00:00
parent 1f79cdcaaf
commit 431052920e
274 changed files with 52782 additions and 7302 deletions

View File

@@ -138,6 +138,75 @@ $transition-speed: .25s;
font-weight: 500;
}
// ── Technician filter chips ─────────────────────────────────────────
.fc_tech_filters {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.fc_tech_chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px 3px 4px;
font-size: 11px;
font-weight: 600;
border: 1px solid $border-color;
border-radius: 14px;
background: transparent;
color: $text-muted;
cursor: pointer;
transition: all .15s;
line-height: 18px;
max-width: 100%;
overflow: hidden;
&:hover {
border-color: rgba($primary, .35);
color: $body-color;
background: rgba($primary, .06);
}
&--active {
background: $primary !important;
color: #fff !important;
border-color: $primary !important;
.fc_tech_chip_avatar {
background: rgba(#fff, .25);
color: #fff;
}
}
&--all {
padding: 3px 10px;
color: $body-color;
font-weight: 500;
&:hover { background: rgba($primary, .1); }
}
}
.fc_tech_chip_avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba($secondary, .15);
color: $body-color;
font-size: 9px;
font-weight: 700;
flex-shrink: 0;
}
.fc_tech_chip_name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Collapsed toggle button (floating)
.fc_sidebar_toggle_btn {
position: absolute;
@@ -320,6 +389,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 +429,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 ──────────────────────────────────────────────────────

View 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" });
},
});

View File

@@ -180,9 +180,22 @@ const SOURCE_COLORS = {
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) {
// Sort by date ASC, time ASC
function groupTasks(tasksData, localInstanceId, visibleTechIds) {
const sorted = [...tasksData].sort((a, b) => {
const da = a.scheduled_date || "";
const db = b.scheduled_date || "";
@@ -190,6 +203,8 @@ function groupTasks(tasksData, localInstanceId) {
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) {
@@ -203,13 +218,17 @@ function groupTasks(tasksData, localInstanceId) {
};
}
let globalIdx = 0;
const dayCounters = {};
for (const task of sorted) {
globalIdx++;
const techId = task.technician_id ? task.technician_id[0] : 0;
if (hasTechFilter && !visibleTechIds[techId]) continue;
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._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";
@@ -227,7 +246,6 @@ function groupTasks(tasksData, localInstanceId) {
groups[g].count++;
}
// Return only non-empty groups in order
return order.map((k) => groups[k]).filter((g) => g.count > 0);
}
@@ -255,21 +273,22 @@ export class FusionTaskMapController extends Component {
showTasks: true,
showTechnicians: true,
showTraffic: true,
showRoute: true,
taskCount: 0,
techCount: 0,
// Sidebar
sidebarOpen: true,
groups: [], // [{key, label, tasks[], count}]
collapsedGroups: {}, // {groupKey: true}
activeTaskId: null, // Highlighted task
// Day filters for map pins (which groups show on map)
groups: [],
collapsedGroups: {},
activeTaskId: null,
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,
},
allTechnicians: [],
visibleTechIds: {},
});
// Yesterday collapsed by default in sidebar list
@@ -280,7 +299,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 +335,7 @@ export class FusionTaskMapController extends Component {
});
onWillUnmount(() => {
this._clearMarkers();
this._clearRoute();
window.__fusionMapOpenTask = () => {};
});
}
@@ -327,17 +351,30 @@ 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.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.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 +382,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 +395,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 +464,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,21 +516,26 @@ export class FusionTaskMapController extends Component {
}
}
// Technician markers
if (this.state.showTechnicians) {
for (const loc of this.locationsData) {
if (!loc.latitude || !loc.longitude) continue;
const pos = { lat: loc.latitude, lng: loc.longitude };
const initials = initialsOf(loc.name);
const 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="#1d4ed8" stroke="#fff" stroke-width="3"/>` +
`<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,
title: loc.name + (isRemote ? ` [${srcLabel}]` : ""),
icon: {
url: "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(svg),
scaledSize: new google.maps.Size(44, 44),
@@ -469,8 +546,9 @@ export class FusionTaskMapController extends Component {
marker.addListener("click", () => {
this.infoWindow.setContent(`
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:200px;color:#1f2937;">
<div style="background:#1d4ed8;color:#fff;padding:10px 14px;">
<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>
@@ -485,45 +563,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">&#x2302;</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};">&#x1F697;</span><span>${timeStr}</span>${distStr ? `<span style="color:#9ca3af;font-weight:500;">&#183; ${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} &nbsp;${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;">
&times;
</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;">&#xf02b;</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;">&#xf017;</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;">&#xf1b9;</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;">&#x1F464;</span><span>${task._techName}</span></div>
<div style="display:flex;align-items:center;gap:6px;"><span style="color:#9ca3af;width:14px;text-align:center;">&#x1F4C5;</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;">&#x1F4CD;</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 &rarr;
<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 &#x2192;
</a>
</div>
</div>`;
@@ -590,6 +1033,28 @@ export class FusionTaskMapController extends Component {
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;
@@ -605,26 +1070,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" },
});
}
}
}

View File

@@ -1048,331 +1048,7 @@ async function setupSimpleAddressFields(el, orm) {
}
}
/**
* Setup autocomplete for LTC Facility form.
* Attaches establishment search on the name field and address search on street.
*/
async function setupFacilityAutocomplete(el, model, orm) {
globalOrm = orm;
const apiKey = await getGoogleMapsApiKey(orm);
if (!apiKey) return;
try { await loadGoogleMapsApi(apiKey); } catch (e) { return; }
// --- Name field: establishment autocomplete ---
const nameSelectors = [
'.oe_title [name="name"] input',
'div[name="name"] input',
'.o_field_widget[name="name"] input',
'[name="name"] input',
];
let nameInput = null;
for (const sel of nameSelectors) {
nameInput = el.querySelector(sel);
if (nameInput) break;
}
if (nameInput && !autocompleteInstances.has('facility_name_' + (nameInput.id || 'default'))) {
_attachFacilityNameAutocomplete(nameInput, el, model);
}
// --- Street field: address autocomplete ---
const streetSelectors = [
'div[name="street"] input',
'.o_field_widget[name="street"] input',
'[name="street"] input',
];
let streetInput = null;
for (const sel of streetSelectors) {
streetInput = el.querySelector(sel);
if (streetInput) break;
}
if (streetInput && !autocompleteInstances.has(streetInput)) {
_attachFacilityAddressAutocomplete(streetInput, el, model);
}
}
/**
* Attach establishment (business) autocomplete on facility name field.
* Selecting a business fills name, address, phone, email, and website.
*/
function _attachFacilityNameAutocomplete(input, el, model) {
if (!input || !window.google?.maps?.places) return;
const instanceKey = 'facility_name_' + (input.id || 'default');
if (autocompleteInstances.has(instanceKey)) return;
const autocomplete = new google.maps.places.Autocomplete(input, {
componentRestrictions: { country: 'ca' },
types: ['establishment'],
fields: [
'place_id', 'name', 'address_components', 'formatted_address',
'formatted_phone_number', 'international_phone_number', 'website',
],
});
autocomplete.addListener('place_changed', async () => {
let place = autocomplete.getPlace();
if (!place.name && !place.place_id) return;
if (place.place_id && !place.formatted_phone_number && !place.website) {
try {
const service = new google.maps.places.PlacesService(document.createElement('div'));
const details = await new Promise((resolve, reject) => {
service.getDetails(
{
placeId: place.place_id,
fields: ['formatted_phone_number', 'international_phone_number', 'website'],
},
(result, status) => {
if (status === google.maps.places.PlacesServiceStatus.OK) resolve(result);
else reject(new Error(status));
}
);
});
if (details.formatted_phone_number) place.formatted_phone_number = details.formatted_phone_number;
if (details.international_phone_number) place.international_phone_number = details.international_phone_number;
if (details.website) place.website = details.website;
} catch (_) { /* ignore */ }
}
let streetNumber = '', streetName = '', unitNumber = '';
let city = '', province = '', postalCode = '', countryCode = '';
if (place.address_components) {
for (const c of place.address_components) {
const t = c.types;
if (t.includes('street_number')) streetNumber = c.long_name;
else if (t.includes('route')) streetName = c.long_name;
else if (t.includes('subpremise')) unitNumber = c.long_name;
else if (t.includes('floor') && !unitNumber) unitNumber = 'Floor ' + c.long_name;
else if (t.includes('locality')) city = c.long_name;
else if (t.includes('sublocality_level_1') && !city) city = c.long_name;
else if (t.includes('administrative_area_level_1')) province = c.short_name;
else if (t.includes('postal_code')) postalCode = c.long_name;
else if (t.includes('country')) countryCode = c.short_name;
}
}
const street = streetNumber ? `${streetNumber} ${streetName}` : streetName;
const phone = place.formatted_phone_number || place.international_phone_number || '';
if (!model?.root) return;
const record = model.root;
let countryId = null, stateId = null;
if (globalOrm && countryCode) {
try {
const [countries, states] = await Promise.all([
globalOrm.searchRead('res.country', [['code', '=', countryCode]], ['id'], { limit: 1 }),
province
? globalOrm.searchRead('res.country.state', [['code', '=', province], ['country_id.code', '=', countryCode]], ['id'], { limit: 1 })
: Promise.resolve([]),
]);
if (countries.length) countryId = countries[0].id;
if (states.length) stateId = states[0].id;
} catch (_) { /* ignore */ }
}
if (record.resId && globalOrm) {
try {
const firstWrite = {};
if (place.name) firstWrite.name = place.name;
if (street) firstWrite.street = street;
if (unitNumber) firstWrite.street2 = unitNumber;
if (city) firstWrite.city = city;
if (postalCode) firstWrite.zip = postalCode;
if (phone) firstWrite.phone = phone;
if (place.website) firstWrite.website = place.website;
if (countryId) firstWrite.country_id = countryId;
await globalOrm.write('fusion.ltc.facility', [record.resId], firstWrite);
if (stateId) {
await new Promise(r => setTimeout(r, 100));
await globalOrm.write('fusion.ltc.facility', [record.resId], { state_id: stateId });
}
await record.load();
} catch (err) {
console.error('[GooglePlaces Facility] Name autocomplete ORM write failed:', err);
}
} else {
try {
const textUpdate = {};
if (place.name) textUpdate.name = place.name;
if (street) textUpdate.street = street;
if (unitNumber) textUpdate.street2 = unitNumber;
if (city) textUpdate.city = city;
if (postalCode) textUpdate.zip = postalCode;
if (phone) textUpdate.phone = phone;
if (place.website) textUpdate.website = place.website;
await record.update(textUpdate);
if (countryId && globalOrm) {
const formEl = input.closest('.o_form_view') || input.closest('.o_content') || document.body;
const [countryData, stateData] = await Promise.all([
globalOrm.read('res.country', [countryId], ['display_name']),
stateId ? globalOrm.read('res.country.state', [stateId], ['display_name']) : Promise.resolve([]),
]);
await simulateMany2OneSelection(formEl, 'country_id', countryId, countryData[0]?.display_name || 'Canada');
await new Promise(r => setTimeout(r, 300));
if (stateId && stateData.length) {
await simulateMany2OneSelection(formEl, 'state_id', stateId, stateData[0]?.display_name || province);
}
}
} catch (err) {
console.error('[GooglePlaces Facility] Name autocomplete update failed:', err);
}
}
});
autocompleteInstances.set(instanceKey, autocomplete);
input.style.backgroundImage = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'%232196F3\'%3E%3Cpath d=\'M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z\'/%3E%3C/svg%3E")';
input.style.backgroundRepeat = 'no-repeat';
input.style.backgroundPosition = 'right 8px center';
input.style.backgroundSize = '20px';
input.style.paddingRight = '35px';
}
/**
* Attach address autocomplete on facility street field.
* Fills street, street2, city, state, zip, and country.
*/
function _attachFacilityAddressAutocomplete(input, el, model) {
if (!input || !window.google?.maps?.places) return;
if (autocompleteInstances.has(input)) return;
const autocomplete = new google.maps.places.Autocomplete(input, {
componentRestrictions: { country: 'ca' },
types: ['address'],
fields: ['address_components', 'formatted_address'],
});
autocomplete.addListener('place_changed', async () => {
const place = autocomplete.getPlace();
if (!place.address_components) return;
let streetNumber = '', streetName = '', unitNumber = '';
let city = '', province = '', postalCode = '', countryCode = '';
for (const c of place.address_components) {
const t = c.types;
if (t.includes('street_number')) streetNumber = c.long_name;
else if (t.includes('route')) streetName = c.long_name;
else if (t.includes('subpremise')) unitNumber = c.long_name;
else if (t.includes('floor') && !unitNumber) unitNumber = 'Floor ' + c.long_name;
else if (t.includes('locality')) city = c.long_name;
else if (t.includes('sublocality_level_1') && !city) city = c.long_name;
else if (t.includes('administrative_area_level_1')) province = c.short_name;
else if (t.includes('postal_code')) postalCode = c.long_name;
else if (t.includes('country')) countryCode = c.short_name;
}
const street = streetNumber ? `${streetNumber} ${streetName}` : streetName;
if (!model?.root) return;
const record = model.root;
let countryId = null, stateId = null;
if (globalOrm && countryCode) {
try {
const [countries, states] = await Promise.all([
globalOrm.searchRead('res.country', [['code', '=', countryCode]], ['id'], { limit: 1 }),
province
? globalOrm.searchRead('res.country.state', [['code', '=', province], ['country_id.code', '=', countryCode]], ['id'], { limit: 1 })
: Promise.resolve([]),
]);
if (countries.length) countryId = countries[0].id;
if (states.length) stateId = states[0].id;
} catch (_) { /* ignore */ }
}
if (record.resId && globalOrm) {
try {
const firstWrite = {};
if (street) firstWrite.street = street;
if (unitNumber) firstWrite.street2 = unitNumber;
if (city) firstWrite.city = city;
if (postalCode) firstWrite.zip = postalCode;
if (countryId) firstWrite.country_id = countryId;
await globalOrm.write('fusion.ltc.facility', [record.resId], firstWrite);
if (stateId) {
await new Promise(r => setTimeout(r, 100));
await globalOrm.write('fusion.ltc.facility', [record.resId], { state_id: stateId });
}
await record.load();
} catch (err) {
console.error('[GooglePlaces Facility] Address ORM write failed:', err);
}
} else {
try {
const textUpdate = {};
if (street) textUpdate.street = street;
if (unitNumber) textUpdate.street2 = unitNumber;
if (city) textUpdate.city = city;
if (postalCode) textUpdate.zip = postalCode;
await record.update(textUpdate);
if (countryId && globalOrm) {
const formEl = input.closest('.o_form_view') || input.closest('.o_content') || document.body;
const [countryData, stateData] = await Promise.all([
globalOrm.read('res.country', [countryId], ['display_name']),
stateId ? globalOrm.read('res.country.state', [stateId], ['display_name']) : Promise.resolve([]),
]);
await simulateMany2OneSelection(formEl, 'country_id', countryId, countryData[0]?.display_name || 'Canada');
await new Promise(r => setTimeout(r, 300));
if (stateId && stateData.length) {
await simulateMany2OneSelection(formEl, 'state_id', stateId, stateData[0]?.display_name || province);
}
}
} catch (err) {
console.error('[GooglePlaces Facility] Address autocomplete update failed:', err);
}
}
setTimeout(() => { _reattachFacilityAutocomplete(el, model); }, 400);
});
autocompleteInstances.set(input, autocomplete);
input.style.backgroundImage = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'%234CAF50\'%3E%3Cpath d=\'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z\'/%3E%3C/svg%3E")';
input.style.backgroundRepeat = 'no-repeat';
input.style.backgroundPosition = 'right 8px center';
input.style.backgroundSize = '20px';
input.style.paddingRight = '35px';
}
/**
* Re-attach facility autocomplete after OWL re-renders inputs.
*/
function _reattachFacilityAutocomplete(el, model) {
const streetSelectors = [
'div[name="street"] input',
'.o_field_widget[name="street"] input',
'[name="street"] input',
];
for (const sel of streetSelectors) {
const inp = el.querySelector(sel);
if (inp && !autocompleteInstances.has(inp)) {
_attachFacilityAddressAutocomplete(inp, el, model);
break;
}
}
}
/** REMOVED: LTC Facility autocomplete functions moved to fusion_ltc_management */
/**
* Patch FormController to add Google autocomplete for partner forms and dialog detection
@@ -1490,35 +1166,6 @@ patch(FormController.prototype, {
}
}
// LTC Facility form
if (this.props.resModel === 'fusion.ltc.facility') {
setTimeout(() => {
if (this.rootRef && this.rootRef.el) {
setupFacilityAutocomplete(this.rootRef.el, this.model, this.orm);
}
}, 800);
if (this.rootRef && this.rootRef.el) {
this._facilityAddrObserver = new MutationObserver((mutations) => {
const hasNewInputs = mutations.some(m =>
m.addedNodes.length > 0 &&
Array.from(m.addedNodes).some(n =>
n.nodeType === 1 && (n.tagName === 'INPUT' || n.querySelector?.('input'))
)
);
if (hasNewInputs) {
setTimeout(() => {
setupFacilityAutocomplete(this.rootRef.el, this.model, this.orm);
}, 300);
}
});
this._facilityAddrObserver.observe(this.rootRef.el, {
childList: true,
subtree: true,
});
}
}
// Simple address autocomplete: res.partner, res.users, res.config.settings
if (this.props.resModel === 'res.partner' || this.props.resModel === 'res.users' || this.props.resModel === 'res.config.settings') {
setTimeout(() => {
@@ -1556,9 +1203,6 @@ patch(FormController.prototype, {
if (this._taskAddressObserver) {
this._taskAddressObserver.disconnect();
}
if (this._facilityAddrObserver) {
this._facilityAddrObserver.disconnect();
}
if (this._simpleAddrObserver) {
this._simpleAddrObserver.disconnect();
}

View File

@@ -928,99 +928,3 @@ html.dark, .o_dark {
}
// ========================================================================
// AI CHAT: Table and response styling for Fusion Claims Intelligence
// ========================================================================
.o-mail-Message-body,
.o-mail-Message-textContent,
.o_mail_body_content {
table {
width: 100%;
border-collapse: collapse;
margin: 8px 0;
font-size: 12px;
line-height: 1.4;
th, td {
border: 1px solid rgba(150, 150, 150, 0.4);
padding: 5px 8px;
text-align: left;
vertical-align: top;
}
th {
background-color: rgba(100, 100, 100, 0.15);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.3px;
white-space: nowrap;
}
td {
white-space: nowrap;
}
tr:nth-child(even) td {
background-color: rgba(100, 100, 100, 0.05);
}
tr:hover td {
background-color: rgba(100, 100, 100, 0.1);
}
}
h3 {
font-size: 14px;
font-weight: 700;
margin: 12px 0 6px 0;
padding-bottom: 4px;
border-bottom: 1px solid rgba(150, 150, 150, 0.3);
}
h4 {
font-size: 13px;
font-weight: 600;
margin: 10px 0 4px 0;
}
strong {
font-weight: 600;
}
code {
background-color: rgba(100, 100, 100, 0.1);
padding: 1px 4px;
border-radius: 3px;
font-size: 11px;
}
ul, ol {
margin: 4px 0;
padding-left: 20px;
}
li {
margin-bottom: 2px;
}
}
html.dark .o-mail-Message-body,
html.dark .o-mail-Message-textContent,
html.dark .o_mail_body_content,
.o_dark .o-mail-Message-body,
.o_dark .o-mail-Message-textContent,
.o_dark .o_mail_body_content {
table {
th, td {
border-color: rgba(200, 200, 200, 0.2);
}
th {
background-color: rgba(200, 200, 200, 0.1);
}
tr:nth-child(even) td {
background-color: rgba(200, 200, 200, 0.04);
}
}
}

View File

@@ -52,6 +52,22 @@
<button class="fc_day_chip fc_day_chip--all" t-on-click="showAllDays"
title="Show all">All</button>
</div>
<!-- Technician filter -->
<t t-if="state.allTechnicians.length > 1">
<div class="fc_tech_filters mt-2">
<t t-foreach="state.allTechnicians" t-as="tech" t-key="tech.id">
<button t-att-class="'fc_tech_chip' + (isTechVisible(tech.id) ? ' fc_tech_chip--active' : '')"
t-on-click="() => this.toggleTechFilter(tech.id)"
t-att-title="tech.name">
<span class="fc_tech_chip_avatar" t-esc="tech.initials"/>
<span class="fc_tech_chip_name" t-esc="tech.name"/>
</button>
</t>
<button class="fc_tech_chip fc_tech_chip--all" t-on-click="showAllTechs"
title="Show all technicians">All</button>
</div>
</t>
</div>
<!-- Sidebar body: grouped task list -->
@@ -113,6 +129,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 +191,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">