mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
1769 lines
62 KiB
JavaScript
1769 lines
62 KiB
JavaScript
import L from "leaflet";
|
|
import { showFlashMessage } from "./helpers";
|
|
import { createPolylinesLayer } from "./polylines";
|
|
|
|
/**
|
|
* Manages visits functionality including displaying, fetching, and interacting with visits
|
|
*/
|
|
export class VisitsManager {
|
|
constructor(map, apiKey, userTheme = 'dark', mapsController = null) {
|
|
this.map = map;
|
|
this.apiKey = apiKey;
|
|
this.userTheme = userTheme;
|
|
this.mapsController = mapsController;
|
|
|
|
// Create custom panes for different visit types
|
|
// Leaflet default panes: tilePane=200, overlayPane=400, shadowPane=500, markerPane=600, tooltipPane=650, popupPane=700
|
|
if (!map.getPane('suggestedVisitsPane')) {
|
|
map.createPane('suggestedVisitsPane');
|
|
map.getPane('suggestedVisitsPane').style.zIndex = 610; // Above markerPane (600), below tooltipPane (650)
|
|
map.getPane('suggestedVisitsPane').style.pointerEvents = 'auto'; // Ensure interactions work
|
|
}
|
|
|
|
if (!map.getPane('confirmedVisitsPane')) {
|
|
map.createPane('confirmedVisitsPane');
|
|
map.getPane('confirmedVisitsPane').style.zIndex = 620; // Above suggested visits
|
|
map.getPane('confirmedVisitsPane').style.pointerEvents = 'auto'; // Ensure interactions work
|
|
}
|
|
|
|
this.visitCircles = L.layerGroup();
|
|
this.confirmedVisitCircles = L.layerGroup().addTo(map); // Always visible layer for confirmed visits
|
|
this.currentPopup = null;
|
|
this.drawerOpen = false;
|
|
this.selectionMode = false;
|
|
this.selectionRect = null;
|
|
this.isSelectionActive = false;
|
|
this.selectedPoints = [];
|
|
this.highlightedVisitId = null;
|
|
this.highlightedCircles = []; // Track multiple circles instead of just one
|
|
|
|
// Add CSS for visit highlighting
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
.visit-highlighted {
|
|
transition: all 0.3s ease-in-out;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
/**
|
|
* Formats a duration in seconds to a human-readable string
|
|
* @param {number} seconds - Duration in seconds
|
|
* @returns {string} Formatted duration string
|
|
*/
|
|
formatDuration(seconds) {
|
|
const days = Math.floor(seconds / (24 * 60 * 60));
|
|
const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60));
|
|
const minutes = Math.floor((seconds % (60 * 60)) / 60);
|
|
|
|
const parts = [];
|
|
if (days > 0) parts.push(`${days}d`);
|
|
if (hours > 0) parts.push(`${hours}h`);
|
|
if (minutes > 0 && days === 0) parts.push(`${minutes}m`); // Only show minutes if less than a day
|
|
|
|
return parts.join(' ') || '< 1m';
|
|
}
|
|
|
|
/**
|
|
* Note: Drawer and selection buttons are now added centrally via addTopRightButtons()
|
|
* in maps_controller.js to ensure correct button ordering.
|
|
*
|
|
* The methods below are kept for backwards compatibility but are no longer called
|
|
* during initialization. Button callbacks are wired directly in maps_controller.js:
|
|
* - onSelectArea -> this.toggleSelectionMode()
|
|
* - onToggleDrawer -> this.toggleDrawer()
|
|
*/
|
|
|
|
/**
|
|
* Toggles the area selection mode
|
|
*/
|
|
toggleSelectionMode() {
|
|
// Clear any existing highlight
|
|
this.clearVisitHighlight();
|
|
|
|
this.isSelectionActive = !this.isSelectionActive;
|
|
if (this.selectionMode) {
|
|
// Disable selection mode
|
|
this.selectionMode = false;
|
|
this.map.dragging.enable();
|
|
document.getElementById('selection-tool-button').classList.remove('active');
|
|
this.map.off('mousedown', this.onMouseDown, this);
|
|
} else {
|
|
// Enable selection mode
|
|
this.selectionMode = true;
|
|
document.getElementById('selection-tool-button').classList.add('active');
|
|
this.map.dragging.disable();
|
|
this.map.on('mousedown', this.onMouseDown, this);
|
|
|
|
showFlashMessage('info', 'Selection mode enabled. Click and drag to select an area.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles the mousedown event to start the selection
|
|
*/
|
|
onMouseDown(e) {
|
|
// Clear any existing selection
|
|
this.clearSelection();
|
|
|
|
// Store start point and create rectangle
|
|
this.startPoint = e.latlng;
|
|
|
|
// Add mousemove and mouseup listeners
|
|
this.map.on('mousemove', this.onMouseMove, this);
|
|
this.map.on('mouseup', this.onMouseUp, this);
|
|
}
|
|
|
|
/**
|
|
* Handles the mousemove event to update the selection rectangle
|
|
*/
|
|
onMouseMove(e) {
|
|
if (!this.startPoint) return;
|
|
|
|
// If we already have a rectangle, update its bounds
|
|
if (this.selectionRect) {
|
|
const bounds = L.latLngBounds(this.startPoint, e.latlng);
|
|
this.selectionRect.setBounds(bounds);
|
|
} else {
|
|
// Create a new rectangle
|
|
this.selectionRect = L.rectangle(
|
|
L.latLngBounds(this.startPoint, e.latlng),
|
|
{ color: '#3388ff', weight: 2, fillOpacity: 0.1 }
|
|
).addTo(this.map);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles the mouseup event to complete the selection
|
|
*/
|
|
onMouseUp(e) {
|
|
// Remove the mouse event listeners
|
|
this.map.off('mousemove', this.onMouseMove, this);
|
|
this.map.off('mouseup', this.onMouseUp, this);
|
|
|
|
if (!this.selectionRect) return;
|
|
|
|
// Finalize the selection
|
|
this.isSelectionActive = true;
|
|
|
|
// Re-enable map dragging
|
|
this.map.dragging.enable();
|
|
|
|
// Disable selection mode
|
|
this.selectionMode = false;
|
|
document.getElementById('selection-tool-button').classList.remove('active');
|
|
this.map.off('mousedown', this.onMouseDown, this);
|
|
|
|
// Fetch visits within the selection
|
|
this.fetchVisitsInSelection();
|
|
}
|
|
|
|
/**
|
|
* Clears the selection rectangle and resets selection state
|
|
*/
|
|
clearSelection() {
|
|
if (this.selectionRect) {
|
|
this.map.removeLayer(this.selectionRect);
|
|
this.selectionRect = null;
|
|
}
|
|
this.isSelectionActive = false;
|
|
this.startPoint = null;
|
|
this.selectedPoints = [];
|
|
|
|
// Clear all visit circles immediately
|
|
this.visitCircles.clearLayers();
|
|
this.confirmedVisitCircles.clearLayers();
|
|
|
|
// Always refresh visits data regardless of drawer state
|
|
// Layer visibility is now controlled by the layer control, not the drawer
|
|
this.fetchAndDisplayVisits();
|
|
|
|
// Reset drawer title
|
|
const drawerTitle = document.querySelector('#visits-drawer .drawer h2');
|
|
if (drawerTitle) {
|
|
drawerTitle.textContent = 'Recent Visits';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches visits within the selected area
|
|
*/
|
|
async fetchVisitsInSelection() {
|
|
if (!this.selectionRect) return;
|
|
|
|
const bounds = this.selectionRect.getBounds();
|
|
const sw = bounds.getSouthWest();
|
|
const ne = bounds.getNorthEast();
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/v1/visits?selection=true&sw_lat=${sw.lat}&sw_lng=${sw.lng}&ne_lat=${ne.lat}&ne_lng=${ne.lng}`,
|
|
{
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${this.apiKey}`,
|
|
}
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Network response was not ok');
|
|
}
|
|
|
|
const visits = await response.json();
|
|
|
|
// Filter points in the selected area from DOM data
|
|
this.filterPointsInSelection(bounds);
|
|
|
|
// Set selection as active to ensure date summary is displayed
|
|
this.isSelectionActive = true;
|
|
|
|
// Make sure the drawer is open FIRST, before displaying visits
|
|
if (!this.drawerOpen) {
|
|
this.toggleDrawer();
|
|
}
|
|
|
|
// Now display visits in the drawer
|
|
this.displayVisits(visits);
|
|
|
|
// Add cancel selection button to the drawer AFTER displayVisits
|
|
// This needs to be after because displayVisits sets innerHTML which would wipe out the buttons
|
|
// Use setTimeout to ensure DOM has fully updated
|
|
setTimeout(() => {
|
|
this.addSelectionCancelButton();
|
|
}, 0);
|
|
|
|
} catch (error) {
|
|
console.error('Error fetching visits in selection:', error);
|
|
showFlashMessage('error', 'Failed to load visits in selected area');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Filters points from DOM data that are within the selection bounds
|
|
* @param {L.LatLngBounds} bounds - The bounds of the selection rectangle
|
|
*/
|
|
filterPointsInSelection(bounds) {
|
|
if (!bounds) {
|
|
this.selectedPoints = [];
|
|
return;
|
|
}
|
|
|
|
// Get points from the DOM
|
|
const allPoints = this.getPointsData();
|
|
if (!allPoints || !allPoints.length) {
|
|
this.selectedPoints = [];
|
|
return;
|
|
}
|
|
|
|
// Filter points that are within the bounds
|
|
this.selectedPoints = allPoints.filter(point => {
|
|
// Point format is expected to be [lat, lng, ...other data]
|
|
const lat = parseFloat(point[0]);
|
|
const lng = parseFloat(point[1]);
|
|
|
|
if (isNaN(lat) || isNaN(lng)) return false;
|
|
|
|
return bounds.contains([lat, lng]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gets points data from the DOM
|
|
* @returns {Array} Array of points with coordinates and timestamps
|
|
*/
|
|
getPointsData() {
|
|
const mapElement = document.getElementById('map');
|
|
if (!mapElement) return [];
|
|
|
|
// Get coordinates data from the data attribute
|
|
const coordinatesAttr = mapElement.getAttribute('data-coordinates');
|
|
if (!coordinatesAttr) return [];
|
|
|
|
try {
|
|
return JSON.parse(coordinatesAttr);
|
|
} catch (e) {
|
|
console.error('Error parsing coordinates data:', e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Groups visits by date
|
|
* @param {Array} visits - Array of visit objects
|
|
* @returns {Object} Object with dates as keys and counts as values
|
|
*/
|
|
groupVisitsByDate(visits) {
|
|
const dateGroups = {};
|
|
|
|
visits.forEach(visit => {
|
|
const startDate = new Date(visit.started_at);
|
|
const dateStr = startDate.toLocaleDateString(undefined, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric'
|
|
});
|
|
|
|
if (!dateGroups[dateStr]) {
|
|
dateGroups[dateStr] = {
|
|
count: 0,
|
|
points: 0,
|
|
date: startDate
|
|
};
|
|
}
|
|
|
|
dateGroups[dateStr].count++;
|
|
});
|
|
|
|
// If we have selected points, count them by date
|
|
if (this.selectedPoints && this.selectedPoints.length > 0) {
|
|
this.selectedPoints.forEach(point => {
|
|
// Point timestamp is at index 4
|
|
const timestamp = point[4];
|
|
if (!timestamp) return;
|
|
|
|
// Convert timestamp to date string
|
|
const pointDate = new Date(parseInt(timestamp) * 1000);
|
|
const dateStr = pointDate.toLocaleDateString(undefined, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric'
|
|
});
|
|
|
|
if (!dateGroups[dateStr]) {
|
|
dateGroups[dateStr] = {
|
|
count: 0,
|
|
points: 0,
|
|
date: pointDate
|
|
};
|
|
}
|
|
|
|
dateGroups[dateStr].points++;
|
|
});
|
|
}
|
|
|
|
return dateGroups;
|
|
}
|
|
|
|
/**
|
|
* Creates HTML for date summary panel
|
|
* @param {Object} dateGroups - Object with dates as keys and count/points values
|
|
* @returns {string} HTML string for date summary panel
|
|
*/
|
|
createDateSummaryHtml(dateGroups) {
|
|
// If there are no date groups, return empty string
|
|
if (Object.keys(dateGroups).length === 0) {
|
|
return '';
|
|
}
|
|
|
|
// Sort dates chronologically
|
|
const sortedDates = Object.keys(dateGroups).sort((a, b) => {
|
|
return dateGroups[a].date - dateGroups[b].date;
|
|
});
|
|
|
|
// Create HTML for each date group
|
|
const dateItems = sortedDates.map(dateStr => {
|
|
const pointsCount = dateGroups[dateStr].points || 0;
|
|
const visitsCount = dateGroups[dateStr].count || 0;
|
|
|
|
return `
|
|
<div class="flex justify-between items-center py-1 border-b border-base-300 last:border-0 my-2 hover:bg-accent hover:text-accent-content transition-colors border-radius-md">
|
|
<div class="font-medium">${dateStr}</div>
|
|
<div class="flex gap-2">
|
|
${pointsCount > 0 ? `<div class="badge badge-secondary">${pointsCount} pts</div>` : ''}
|
|
${visitsCount > 0 ? `<div class="badge badge-primary">${visitsCount} visits</div>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
// Create the whole panel with collapsible content
|
|
return `
|
|
<details class="collapse collapse-arrow bg-base-100 rounded-lg mb-4 shadow-sm">
|
|
<summary class="collapse-title text-lg font-bold">
|
|
Data in Selected Area
|
|
</summary>
|
|
<div class="collapse-content">
|
|
<div class="divide-y divide-base-300">
|
|
${dateItems}
|
|
</div>
|
|
</div>
|
|
</details>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Adds a cancel button to the drawer to clear the selection
|
|
*/
|
|
addSelectionCancelButton() {
|
|
console.log('addSelectionCancelButton: Called');
|
|
const container = document.getElementById('visits-list');
|
|
if (!container) {
|
|
console.error('addSelectionCancelButton: visits-list container not found');
|
|
return;
|
|
}
|
|
console.log('addSelectionCancelButton: Container found');
|
|
|
|
// Remove any existing button container first to avoid duplicates
|
|
const existingButtonContainer = document.getElementById('selection-button-container');
|
|
if (existingButtonContainer) {
|
|
console.log('addSelectionCancelButton: Removing existing button container');
|
|
existingButtonContainer.remove();
|
|
}
|
|
|
|
// Create a button container
|
|
const buttonContainer = document.createElement('div');
|
|
buttonContainer.className = 'flex flex-col gap-2 mb-4';
|
|
buttonContainer.id = 'selection-button-container';
|
|
|
|
// Cancel button
|
|
const cancelButton = document.createElement('button');
|
|
cancelButton.id = 'cancel-selection-button';
|
|
cancelButton.className = 'btn btn-sm btn-warning w-full';
|
|
cancelButton.textContent = 'Cancel Selection';
|
|
cancelButton.onclick = () => this.clearSelection();
|
|
|
|
// Delete all selected points button
|
|
const deleteButton = document.createElement('button');
|
|
deleteButton.id = 'delete-selection-button';
|
|
deleteButton.className = 'btn btn-sm btn-error w-full';
|
|
deleteButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline mr-1"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>Delete Points';
|
|
deleteButton.onclick = () => this.deleteSelectedPoints();
|
|
|
|
// Add count badge if we have selected points
|
|
if (this.selectedPoints && this.selectedPoints.length > 0) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'badge badge-sm ml-1';
|
|
badge.textContent = this.selectedPoints.length;
|
|
deleteButton.appendChild(badge);
|
|
console.log(`addSelectionCancelButton: Added badge with ${this.selectedPoints.length} points`);
|
|
} else {
|
|
console.warn('addSelectionCancelButton: No selected points, selectedPoints =', this.selectedPoints);
|
|
}
|
|
|
|
buttonContainer.appendChild(cancelButton);
|
|
buttonContainer.appendChild(deleteButton);
|
|
|
|
// Insert at the beginning of the container
|
|
container.insertBefore(buttonContainer, container.firstChild);
|
|
console.log('addSelectionCancelButton: Buttons inserted into DOM');
|
|
|
|
// Verify buttons are in DOM
|
|
setTimeout(() => {
|
|
const verifyDelete = document.getElementById('delete-selection-button');
|
|
const verifyCancel = document.getElementById('cancel-selection-button');
|
|
console.log('addSelectionCancelButton: Verification - Delete button exists:', !!verifyDelete);
|
|
console.log('addSelectionCancelButton: Verification - Cancel button exists:', !!verifyCancel);
|
|
}, 100);
|
|
}
|
|
|
|
/**
|
|
* Deletes all points in the current selection
|
|
*/
|
|
async deleteSelectedPoints() {
|
|
if (!this.selectedPoints || this.selectedPoints.length === 0) {
|
|
showFlashMessage('warning', 'No points selected');
|
|
return;
|
|
}
|
|
|
|
const pointCount = this.selectedPoints.length;
|
|
const confirmed = confirm(
|
|
`⚠️ WARNING: This will permanently delete ${pointCount} point${pointCount > 1 ? 's' : ''} from your location history.\n\n` +
|
|
`This action cannot be undone!\n\n` +
|
|
`Are you sure you want to continue?`
|
|
);
|
|
|
|
if (!confirmed) return;
|
|
|
|
try {
|
|
// Get point IDs from the selected points
|
|
// Debug: log the structure of selected points
|
|
console.log('Selected points sample:', this.selectedPoints[0]);
|
|
|
|
// Points format: [lat, lng, ?, ?, timestamp, ?, id, country, ?]
|
|
// ID is at index 6 based on the marker array structure
|
|
const pointIds = this.selectedPoints
|
|
.map(point => point[6]) // ID is at index 6
|
|
.filter(id => id != null && id !== '');
|
|
|
|
console.log('Point IDs to delete:', pointIds);
|
|
|
|
if (pointIds.length === 0) {
|
|
showFlashMessage('error', 'No valid point IDs found');
|
|
return;
|
|
}
|
|
|
|
// Call the bulk delete API
|
|
const response = await fetch('/api/v1/points/bulk_destroy', {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${this.apiKey}`,
|
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
|
|
},
|
|
body: JSON.stringify({ point_ids: pointIds })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error('Response error:', response.status, errorText);
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
console.log('Delete result:', result);
|
|
|
|
// Check if any points were actually deleted
|
|
if (result.count === 0) {
|
|
showFlashMessage('warning', 'No points were deleted. They may have already been removed.');
|
|
this.clearSelection();
|
|
return;
|
|
}
|
|
|
|
// Show success message
|
|
showFlashMessage('notice', `Successfully deleted ${result.count} point${result.count > 1 ? 's' : ''}`);
|
|
|
|
// Remove deleted points from the map
|
|
pointIds.forEach(id => {
|
|
this.mapsController.removeMarker(id);
|
|
});
|
|
|
|
// Update the polylines layer
|
|
this.updatePolylinesAfterDeletion();
|
|
|
|
// Update heatmap with remaining markers
|
|
if (this.mapsController.heatmapLayer) {
|
|
this.mapsController.heatmapLayer.setLatLngs(
|
|
this.mapsController.markers.map(marker => [marker[0], marker[1], 0.2])
|
|
);
|
|
}
|
|
|
|
// Update fog if enabled
|
|
if (this.mapsController.fogOverlay && this.mapsController.map.hasLayer(this.mapsController.fogOverlay)) {
|
|
this.mapsController.updateFog(
|
|
this.mapsController.markers,
|
|
this.mapsController.clearFogRadius,
|
|
this.mapsController.fogLineThreshold
|
|
);
|
|
}
|
|
|
|
// Clear selection
|
|
this.clearSelection();
|
|
|
|
} catch (error) {
|
|
console.error('Error deleting points:', error);
|
|
showFlashMessage('error', 'Failed to delete points. Please try again.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates polylines layer after deletion (similar to single point deletion)
|
|
*/
|
|
updatePolylinesAfterDeletion() {
|
|
let wasPolyLayerVisible = false;
|
|
|
|
// Check if polylines layer was visible
|
|
if (this.mapsController.polylinesLayer) {
|
|
if (this.mapsController.map.hasLayer(this.mapsController.polylinesLayer)) {
|
|
wasPolyLayerVisible = true;
|
|
}
|
|
this.mapsController.map.removeLayer(this.mapsController.polylinesLayer);
|
|
}
|
|
|
|
// Create new polylines layer with updated markers
|
|
this.mapsController.polylinesLayer = createPolylinesLayer(
|
|
this.mapsController.markers,
|
|
this.mapsController.map,
|
|
this.mapsController.timezone,
|
|
this.mapsController.routeOpacity,
|
|
this.mapsController.userSettings,
|
|
this.mapsController.distanceUnit
|
|
);
|
|
|
|
// Re-add to map if it was visible, otherwise ensure it's removed
|
|
if (wasPolyLayerVisible) {
|
|
this.mapsController.polylinesLayer.addTo(this.mapsController.map);
|
|
} else {
|
|
this.mapsController.map.removeLayer(this.mapsController.polylinesLayer);
|
|
}
|
|
|
|
// Update layer control
|
|
if (this.mapsController.layerControl) {
|
|
this.mapsController.map.removeControl(this.mapsController.layerControl);
|
|
const controlsLayer = {
|
|
Points: this.mapsController.markersLayer || L.layerGroup(),
|
|
Routes: this.mapsController.polylinesLayer || L.layerGroup(),
|
|
Heatmap: this.mapsController.heatmapLayer || L.layerGroup(),
|
|
"Fog of War": this.mapsController.fogOverlay,
|
|
"Scratch map": this.mapsController.scratchLayerManager?.getLayer() || L.layerGroup(),
|
|
Areas: this.mapsController.areasLayer || L.layerGroup(),
|
|
Photos: this.mapsController.photoMarkers || L.layerGroup()
|
|
};
|
|
this.mapsController.layerControl = L.control.layers(
|
|
this.mapsController.baseMaps(),
|
|
controlsLayer
|
|
).addTo(this.mapsController.map);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggles the visibility of the visits drawer
|
|
*/
|
|
toggleDrawer() {
|
|
// Clear any existing highlight when drawer is toggled
|
|
this.clearVisitHighlight();
|
|
|
|
this.drawerOpen = !this.drawerOpen;
|
|
let drawer = document.getElementById('visits-drawer');
|
|
|
|
if (!drawer) {
|
|
drawer = this.createDrawer();
|
|
}
|
|
|
|
drawer.classList.toggle('open');
|
|
|
|
const drawerButton = document.querySelector('.drawer-button');
|
|
if (drawerButton) {
|
|
drawerButton.innerHTML = this.drawerOpen ? '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panel-right-close-icon lucide-panel-right-close"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m8 9 3 3-3 3"/></svg>' : '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panel-right-open-icon lucide-panel-right-open"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m10 15-3-3 3-3"/></svg>';
|
|
}
|
|
|
|
// Update the drawer content if it's being opened - but don't fetch visits automatically
|
|
// Only show the "no data" message if there's no selection active
|
|
if (this.drawerOpen && !this.isSelectionActive) {
|
|
const container = document.getElementById('visits-list');
|
|
if (container) {
|
|
container.innerHTML = `
|
|
<div class="text-gray-500 text-center p-4">
|
|
<p class="mb-2">No visits data loaded</p>
|
|
<p class="text-sm">Enable "Suggested Visits" or "Confirmed Visits" layers from the map controls to view visits.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
// Note: Layer visibility is now controlled by the layer control, not the drawer state
|
|
}
|
|
|
|
/**
|
|
* Creates the drawer element for displaying visits
|
|
* @returns {HTMLElement} The created drawer element
|
|
*/
|
|
createDrawer() {
|
|
const drawer = document.createElement('div');
|
|
drawer.id = 'visits-drawer';
|
|
drawer.className = 'bg-base-100 shadow-lg z-39 overflow-y-auto leaflet-drawer';
|
|
|
|
// Add styles to make the drawer scrollable
|
|
drawer.style.overflowY = 'auto';
|
|
|
|
drawer.innerHTML = `
|
|
<div class="p-2 my-2 drawer flex flex-col items-center">
|
|
<h2 class="text-xl font-bold mb-4 text-accent-content w-full">Recent Visits</h2>
|
|
<div id="visits-list" class="space-y-2 w-full">
|
|
<p class="text-gray-500">Loading visits...</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Prevent map zoom when scrolling the drawer
|
|
L.DomEvent.disableScrollPropagation(drawer);
|
|
// Prevent map pan/interaction when interacting with drawer
|
|
L.DomEvent.disableClickPropagation(drawer);
|
|
|
|
this.map.getContainer().appendChild(drawer);
|
|
return drawer;
|
|
}
|
|
|
|
/**
|
|
* Fetches visits data from the API and displays them
|
|
*/
|
|
async fetchAndDisplayVisits() {
|
|
try {
|
|
console.log('fetchAndDisplayVisits called');
|
|
// Clear any existing highlight before fetching new visits
|
|
this.clearVisitHighlight();
|
|
|
|
// If there's an active selection, don't perform time-based fetch
|
|
if (this.isSelectionActive && this.selectionRect) {
|
|
console.log('Active selection found, fetching visits in selection');
|
|
this.fetchVisitsInSelection();
|
|
return;
|
|
}
|
|
|
|
// Get current timeframe from URL parameters
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const startAt = urlParams.get('start_at') || new Date().toISOString();
|
|
const endAt = urlParams.get('end_at') || new Date().toISOString();
|
|
|
|
console.log('Fetching visits for date range:', { startAt, endAt });
|
|
const response = await fetch(
|
|
`/api/v1/visits?start_at=${encodeURIComponent(startAt)}&end_at=${encodeURIComponent(endAt)}`,
|
|
{
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${this.apiKey}`,
|
|
}
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
console.error('Visits API response not ok:', response.status, response.statusText);
|
|
throw new Error('Network response was not ok');
|
|
}
|
|
|
|
const visits = await response.json();
|
|
console.log('Visits API response:', { count: visits.length, visits });
|
|
this.displayVisits(visits);
|
|
|
|
// Let the layer control manage visibility instead of drawer state
|
|
console.log('Visit circles populated - layer control will manage visibility');
|
|
console.log('visitCircles layer count:', this.visitCircles.getLayers().length);
|
|
console.log('confirmedVisitCircles layer count:', this.confirmedVisitCircles.getLayers().length);
|
|
|
|
// Check if the layers are currently enabled in the layer control and ensure they're visible
|
|
const layerControl = this.map._layers;
|
|
let suggestedVisitsEnabled = false;
|
|
let confirmedVisitsEnabled = false;
|
|
|
|
// Check layer control state
|
|
Object.values(layerControl || {}).forEach(layer => {
|
|
if (layer.name === 'Suggested Visits' && this.map.hasLayer(layer.layer)) {
|
|
suggestedVisitsEnabled = true;
|
|
}
|
|
if (layer.name === 'Confirmed Visits' && this.map.hasLayer(layer.layer)) {
|
|
confirmedVisitsEnabled = true;
|
|
}
|
|
});
|
|
|
|
console.log('Layer control state:', { suggestedVisitsEnabled, confirmedVisitsEnabled });
|
|
} catch (error) {
|
|
console.error('Error fetching visits:', error);
|
|
const container = document.getElementById('visits-list');
|
|
if (container) {
|
|
container.innerHTML = '<p class="text-red-500">Error loading visits</p>';
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates visit circles on the map (independent of drawer UI)
|
|
* @param {Array} visits - Array of visit objects
|
|
*/
|
|
createMapCircles(visits) {
|
|
if (!visits || visits.length === 0) {
|
|
console.log('No visits to create circles for');
|
|
return;
|
|
}
|
|
|
|
// Clear existing visit circles
|
|
console.log('Clearing existing visit circles');
|
|
this.visitCircles.clearLayers();
|
|
this.confirmedVisitCircles.clearLayers();
|
|
|
|
let suggestedCount = 0;
|
|
let confirmedCount = 0;
|
|
|
|
// Draw circles for all visits
|
|
visits
|
|
.filter(visit => visit.status !== 'declined')
|
|
.forEach(visit => {
|
|
if (visit.place?.latitude && visit.place?.longitude) {
|
|
const isConfirmed = visit.status === 'confirmed';
|
|
const isSuggested = visit.status === 'suggested';
|
|
|
|
console.log('Creating circle for visit:', {
|
|
id: visit.id,
|
|
status: visit.status,
|
|
lat: visit.place.latitude,
|
|
lng: visit.place.longitude,
|
|
isConfirmed,
|
|
isSuggested
|
|
});
|
|
|
|
const circle = L.circle([visit.place.latitude, visit.place.longitude], {
|
|
color: isSuggested ? '#FFA500' : '#4A90E2', // Border color
|
|
fillColor: isSuggested ? '#FFD700' : '#4A90E2', // Fill color
|
|
fillOpacity: isSuggested ? 0.3 : 0.5,
|
|
radius: isConfirmed ? 110 : 80, // Increased size for confirmed visits
|
|
weight: 2,
|
|
interactive: true,
|
|
bubblingMouseEvents: false,
|
|
pane: isConfirmed ? 'confirmedVisitsPane' : 'suggestedVisitsPane', // Use appropriate pane
|
|
dashArray: isSuggested ? '4' : null // Dotted border for suggested
|
|
});
|
|
|
|
// Add the circle to the appropriate layer
|
|
if (isConfirmed) {
|
|
this.confirmedVisitCircles.addLayer(circle);
|
|
confirmedCount++;
|
|
console.log('Added confirmed visit circle to layer');
|
|
} else {
|
|
this.visitCircles.addLayer(circle);
|
|
suggestedCount++;
|
|
console.log('Added suggested visit circle to layer');
|
|
}
|
|
|
|
// Attach click event to the circle
|
|
circle.on('click', () => this.fetchPossiblePlaces(visit));
|
|
} else {
|
|
console.warn('Visit missing coordinates:', visit);
|
|
}
|
|
});
|
|
|
|
console.log('Visit circles created:', { suggestedCount, confirmedCount });
|
|
}
|
|
|
|
/**
|
|
* Displays visits on the map and in the drawer
|
|
* @param {Array} visits - Array of visit objects
|
|
*/
|
|
displayVisits(visits) {
|
|
// Always create map circles regardless of drawer state
|
|
this.createMapCircles(visits);
|
|
|
|
// Update drawer UI only if container exists
|
|
const container = document.getElementById('visits-list');
|
|
if (!container) {
|
|
console.log('No visits-list container found - skipping drawer UI update');
|
|
return;
|
|
}
|
|
|
|
// Update the drawer title if selection is active
|
|
if (this.isSelectionActive && this.selectionRect) {
|
|
const visitsCount = visits ? visits.filter(visit => visit.status !== 'declined').length : 0;
|
|
const drawerTitle = document.querySelector('#visits-drawer .drawer h2');
|
|
if (drawerTitle) {
|
|
drawerTitle.textContent = `${visitsCount} visits found`;
|
|
}
|
|
} else {
|
|
// Reset title to default when not in selection mode
|
|
const drawerTitle = document.querySelector('#visits-drawer .drawer h2');
|
|
if (drawerTitle) {
|
|
drawerTitle.textContent = 'Recent Visits';
|
|
}
|
|
}
|
|
|
|
// Group visits by date and count
|
|
const dateGroups = this.groupVisitsByDate(visits || []);
|
|
|
|
// If we have points data and are in selection mode, calculate points per date
|
|
let dateGroupsHtml = '';
|
|
if (this.isSelectionActive && this.selectionRect) {
|
|
// Create a date summary panel
|
|
dateGroupsHtml = this.createDateSummaryHtml(dateGroups);
|
|
}
|
|
|
|
if (!visits || visits.length === 0) {
|
|
let noVisitsHtml = '<p class="text-gray-500">No visits found in selected timeframe</p>';
|
|
container.innerHTML = dateGroupsHtml + noVisitsHtml;
|
|
return;
|
|
}
|
|
|
|
// Map circles are handled by createMapCircles() - just generate drawer HTML
|
|
const visitsHtml = visits
|
|
// Filter out declined visits
|
|
.filter(visit => visit.status !== 'declined')
|
|
.map(visit => {
|
|
const startDate = new Date(visit.started_at);
|
|
const endDate = new Date(visit.ended_at);
|
|
const isSameDay = startDate.toDateString() === endDate.toDateString();
|
|
|
|
let timeDisplay;
|
|
if (isSameDay) {
|
|
timeDisplay = `
|
|
${startDate.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })},
|
|
${startDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} -
|
|
${endDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })}
|
|
`;
|
|
} else {
|
|
timeDisplay = `
|
|
${startDate.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })},
|
|
${startDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} -
|
|
${endDate.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })},
|
|
${endDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })}
|
|
`;
|
|
}
|
|
|
|
const durationText = this.formatDuration(visit.duration * 60);
|
|
|
|
// Add opacity class for suggested visits
|
|
const bgClass = visit.status === 'suggested' ? 'bg-neutral border-dashed border-2 border-sky-500' : 'bg-base-200';
|
|
const visitStyle = visit.status === 'suggested' ? 'border: 2px dashed #60a5fa;' : '';
|
|
|
|
return `
|
|
<div class="w-full p-3 mt-2 rounded-lg hover:bg-base-300 transition-colors visit-item relative ${bgClass}"
|
|
style="${visitStyle}"
|
|
data-lat="${visit.place?.latitude || ''}"
|
|
data-lng="${visit.place?.longitude || ''}"
|
|
data-id="${visit.id}">
|
|
<div class="absolute top-2 left-2 opacity-0 transition-opacity duration-200 visit-checkbox-container">
|
|
<input type="checkbox" class="checkbox checkbox-sm visit-checkbox" data-id="${visit.id}">
|
|
</div>
|
|
<div class="font-semibold overflow-hidden text-ellipsis whitespace-nowrap pl-6" title="${visit.name}">${this.truncateText(visit.name, 30)}</div>
|
|
<div class="text-sm text-gray-600">
|
|
${timeDisplay.trim()}
|
|
<div class="text-gray-500">(${durationText})</div>
|
|
</div>
|
|
${visit.place?.city ? `<div class="text-sm">${visit.place.city}, ${visit.place.country}</div>` : ''}
|
|
${visit.status !== 'confirmed' ? `
|
|
<div class="flex gap-2 mt-2">
|
|
<button class="btn btn-xs btn-success confirm-visit" data-id="${visit.id}">
|
|
Confirm
|
|
</button>
|
|
<button class="btn btn-xs btn-error decline-visit" data-id="${visit.id}">
|
|
Decline
|
|
</button>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
// Wrap visits in a collapsible section
|
|
const visitsSection = visits && visits.length > 0 ? `
|
|
<details class="collapse collapse-arrow bg-base-100 rounded-lg mb-4 shadow-sm">
|
|
<summary class="collapse-title text-lg font-bold">
|
|
Visits (${visits.filter(v => v.status !== 'declined').length})
|
|
</summary>
|
|
<div class="collapse-content">
|
|
${visitsHtml}
|
|
</div>
|
|
</details>
|
|
` : '';
|
|
|
|
// Combine date summary and visits HTML
|
|
container.innerHTML = dateGroupsHtml + visitsSection;
|
|
|
|
// Add the circles layer to the map
|
|
this.visitCircles.addTo(this.map);
|
|
|
|
// Add click handlers to visit items and buttons
|
|
this.addVisitItemEventListeners(container);
|
|
|
|
// Add merge functionality
|
|
this.setupMergeFunctionality(container);
|
|
|
|
// Ensure all checkboxes are hidden by default
|
|
container.querySelectorAll('.visit-checkbox-container').forEach(checkboxContainer => {
|
|
checkboxContainer.style.opacity = '0';
|
|
checkboxContainer.style.pointerEvents = 'none';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sets up the merge functionality for visits
|
|
* @param {HTMLElement} container - The container with visit items
|
|
*/
|
|
setupMergeFunctionality(container) {
|
|
const visitItems = container.querySelectorAll('.visit-item');
|
|
|
|
// Add hover event to show checkboxes
|
|
visitItems.forEach(item => {
|
|
// Show checkbox on hover only if no checkboxes are currently checked
|
|
item.addEventListener('mouseenter', () => {
|
|
const allChecked = container.querySelectorAll('.visit-checkbox:checked');
|
|
if (allChecked.length === 0) {
|
|
const checkbox = item.querySelector('.visit-checkbox-container');
|
|
if (checkbox) {
|
|
checkbox.style.opacity = '1';
|
|
checkbox.style.pointerEvents = 'auto';
|
|
}
|
|
}
|
|
});
|
|
|
|
// Hide checkbox on mouse leave if not checked and if no other checkboxes are checked
|
|
item.addEventListener('mouseleave', () => {
|
|
const allChecked = container.querySelectorAll('.visit-checkbox:checked');
|
|
if (allChecked.length === 0) {
|
|
const checkbox = item.querySelector('.visit-checkbox-container');
|
|
const checkboxInput = item.querySelector('.visit-checkbox');
|
|
if (checkbox && checkboxInput && !checkboxInput.checked) {
|
|
checkbox.style.opacity = '0';
|
|
checkbox.style.pointerEvents = 'none';
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Add change event to checkboxes
|
|
const checkboxes = container.querySelectorAll('.visit-checkbox');
|
|
checkboxes.forEach(checkbox => {
|
|
checkbox.addEventListener('change', () => {
|
|
this.updateMergeUI(container);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Updates the merge UI based on selected checkboxes
|
|
* @param {HTMLElement} container - The container with visit items
|
|
*/
|
|
updateMergeUI(container) {
|
|
// Remove any existing action buttons
|
|
const existingActionButtons = container.querySelector('.visit-bulk-actions');
|
|
if (existingActionButtons) {
|
|
existingActionButtons.remove();
|
|
}
|
|
|
|
// Get all checked checkboxes
|
|
const checkedBoxes = container.querySelectorAll('.visit-checkbox:checked');
|
|
|
|
// Hide all checkboxes first
|
|
container.querySelectorAll('.visit-checkbox-container').forEach(checkboxContainer => {
|
|
checkboxContainer.style.opacity = '0';
|
|
checkboxContainer.style.pointerEvents = 'none';
|
|
});
|
|
|
|
// If no checkboxes are checked, we're done
|
|
if (checkedBoxes.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Get all visit items and their data
|
|
const visitItems = Array.from(container.querySelectorAll('.visit-item'));
|
|
|
|
// For each checked visit, show checkboxes for adjacent visits
|
|
Array.from(checkedBoxes).forEach(checkbox => {
|
|
const visitItem = checkbox.closest('.visit-item');
|
|
const visitId = checkbox.dataset.id;
|
|
const index = visitItems.indexOf(visitItem);
|
|
|
|
// Show checkbox for the current visit
|
|
const currentCheckbox = visitItem.querySelector('.visit-checkbox-container');
|
|
if (currentCheckbox) {
|
|
currentCheckbox.style.opacity = '1';
|
|
currentCheckbox.style.pointerEvents = 'auto';
|
|
}
|
|
|
|
// Show checkboxes for visits above and below
|
|
// Above visit
|
|
if (index > 0) {
|
|
const aboveVisitItem = visitItems[index - 1];
|
|
const aboveCheckbox = aboveVisitItem.querySelector('.visit-checkbox-container');
|
|
if (aboveCheckbox) {
|
|
aboveCheckbox.style.opacity = '1';
|
|
aboveCheckbox.style.pointerEvents = 'auto';
|
|
}
|
|
}
|
|
|
|
// Below visit
|
|
if (index < visitItems.length - 1) {
|
|
const belowVisitItem = visitItems[index + 1];
|
|
const belowCheckbox = belowVisitItem.querySelector('.visit-checkbox-container');
|
|
if (belowCheckbox) {
|
|
belowCheckbox.style.opacity = '1';
|
|
belowCheckbox.style.pointerEvents = 'auto';
|
|
}
|
|
}
|
|
});
|
|
|
|
// If 2 or more checkboxes are checked, show action buttons
|
|
if (checkedBoxes.length >= 2) {
|
|
// Find the lowest checked visit item
|
|
let lowestVisitItem = null;
|
|
let lowestPosition = -1;
|
|
|
|
checkedBoxes.forEach(checkbox => {
|
|
const visitItem = checkbox.closest('.visit-item');
|
|
const position = visitItems.indexOf(visitItem);
|
|
|
|
if (lowestPosition === -1 || position > lowestPosition) {
|
|
lowestPosition = position;
|
|
lowestVisitItem = visitItem;
|
|
}
|
|
});
|
|
|
|
// Create action buttons container
|
|
if (lowestVisitItem) {
|
|
// Create a container for the action buttons to ensure proper spacing
|
|
const actionsContainer = document.createElement('div');
|
|
actionsContainer.className = 'w-full p-2 visit-bulk-actions';
|
|
|
|
// Create button grid
|
|
const buttonGrid = document.createElement('div');
|
|
buttonGrid.className = 'grid grid-cols-3 gap-2';
|
|
|
|
// Merge button
|
|
const mergeButton = document.createElement('button');
|
|
mergeButton.className = 'btn btn-xs btn-primary';
|
|
mergeButton.textContent = 'Merge';
|
|
mergeButton.addEventListener('click', () => {
|
|
this.mergeVisits(Array.from(checkedBoxes).map(cb => cb.dataset.id));
|
|
});
|
|
|
|
// Confirm button
|
|
const confirmButton = document.createElement('button');
|
|
confirmButton.className = 'btn btn-xs btn-success';
|
|
confirmButton.textContent = 'Confirm';
|
|
confirmButton.addEventListener('click', () => {
|
|
this.bulkUpdateVisitStatus(Array.from(checkedBoxes).map(cb => cb.dataset.id), 'confirmed');
|
|
});
|
|
|
|
// Decline button
|
|
const declineButton = document.createElement('button');
|
|
declineButton.className = 'btn btn-xs btn-error';
|
|
declineButton.textContent = 'Decline';
|
|
declineButton.addEventListener('click', () => {
|
|
this.bulkUpdateVisitStatus(Array.from(checkedBoxes).map(cb => cb.dataset.id), 'declined');
|
|
});
|
|
|
|
// Add buttons to grid
|
|
buttonGrid.appendChild(mergeButton);
|
|
buttonGrid.appendChild(confirmButton);
|
|
buttonGrid.appendChild(declineButton);
|
|
|
|
// Add selection count text
|
|
const selectionText = document.createElement('div');
|
|
selectionText.className = 'text-sm text-center mt-1 text-gray-500';
|
|
selectionText.textContent = `${checkedBoxes.length} visits selected`;
|
|
|
|
// Add cancel selection button
|
|
const cancelButton = document.createElement('button');
|
|
cancelButton.className = 'btn btn-xs btn-neutral w-full mt-2';
|
|
cancelButton.textContent = 'Cancel Selection';
|
|
cancelButton.addEventListener('click', () => {
|
|
// Uncheck all checkboxes
|
|
checkedBoxes.forEach(checkbox => {
|
|
checkbox.checked = false;
|
|
});
|
|
// Update UI to remove action buttons
|
|
this.updateMergeUI(container);
|
|
});
|
|
|
|
// Add elements to container
|
|
actionsContainer.appendChild(buttonGrid);
|
|
actionsContainer.appendChild(selectionText);
|
|
actionsContainer.appendChild(cancelButton);
|
|
|
|
// Insert after the lowest visit item
|
|
lowestVisitItem.insertAdjacentElement('afterend', actionsContainer);
|
|
}
|
|
}
|
|
|
|
// Show all checkboxes when at least one is checked
|
|
const checkboxContainers = container.querySelectorAll('.visit-checkbox-container');
|
|
checkboxContainers.forEach(checkboxContainer => {
|
|
checkboxContainer.style.opacity = '1';
|
|
checkboxContainer.style.pointerEvents = 'auto';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sends a request to merge the selected visits
|
|
* @param {Array} visitIds - Array of visit IDs to merge
|
|
*/
|
|
async mergeVisits(visitIds) {
|
|
if (!visitIds || visitIds.length < 2) {
|
|
showFlashMessage('error', 'At least 2 visits must be selected for merging');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/v1/visits/merge', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${this.apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
visit_ids: visitIds
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to merge visits');
|
|
}
|
|
|
|
showFlashMessage('notice', 'Visits merged successfully');
|
|
|
|
// Refresh the visits list
|
|
this.fetchAndDisplayVisits();
|
|
} catch (error) {
|
|
console.error('Error merging visits:', error);
|
|
showFlashMessage('error', 'Failed to merge visits');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sends a request to update status for multiple visits
|
|
* @param {Array} visitIds - Array of visit IDs to update
|
|
* @param {string} status - The new status ('confirmed' or 'declined')
|
|
*/
|
|
async bulkUpdateVisitStatus(visitIds, status) {
|
|
if (!visitIds || visitIds.length === 0) {
|
|
showFlashMessage('error', 'No visits selected');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/v1/visits/bulk_update', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${this.apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
visit_ids: visitIds,
|
|
status: status
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to ${status} visits`);
|
|
}
|
|
|
|
showFlashMessage('notice', `${visitIds.length} visits ${status === 'confirmed' ? 'confirmed' : 'declined'} successfully`);
|
|
|
|
// Refresh the visits list
|
|
this.fetchAndDisplayVisits();
|
|
} catch (error) {
|
|
console.error(`Error ${status}ing visits:`, error);
|
|
showFlashMessage('error', `Failed to ${status} visits`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds event listeners to visit items in the drawer
|
|
* @param {HTMLElement} container - The container element with visit items
|
|
*/
|
|
addVisitItemEventListeners(container) {
|
|
const visitItems = container.querySelectorAll('.visit-item');
|
|
|
|
// Remove existing highlight if any
|
|
this.clearVisitHighlight();
|
|
|
|
visitItems.forEach(item => {
|
|
// Location click handler
|
|
item.addEventListener('click', (event) => {
|
|
// Don't trigger if clicking on buttons or checkboxes
|
|
if (event.target.classList.contains('btn') ||
|
|
event.target.classList.contains('checkbox') ||
|
|
event.target.closest('.visit-checkbox-container')) {
|
|
return;
|
|
}
|
|
|
|
const visitId = item.dataset.id;
|
|
const lat = parseFloat(item.dataset.lat);
|
|
const lng = parseFloat(item.dataset.lng);
|
|
|
|
// Highlight the clicked visit
|
|
this.highlightVisit(visitId, item, [lat, lng]);
|
|
|
|
if (!isNaN(lat) && !isNaN(lng)) {
|
|
this.map.setView([lat, lng], 15, {
|
|
animate: true,
|
|
duration: 1
|
|
});
|
|
}
|
|
});
|
|
|
|
// Confirm button handler
|
|
const confirmBtn = item.querySelector('.confirm-visit');
|
|
confirmBtn?.addEventListener('click', async (event) => {
|
|
event.stopPropagation();
|
|
const visitId = event.target.dataset.id;
|
|
try {
|
|
const response = await fetch(`/api/v1/visits/${visitId}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${this.apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
visit: {
|
|
status: 'confirmed'
|
|
}
|
|
})
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to confirm visit');
|
|
|
|
// Refresh visits list
|
|
this.fetchAndDisplayVisits();
|
|
showFlashMessage('notice', 'Visit confirmed successfully');
|
|
} catch (error) {
|
|
console.error('Error confirming visit:', error);
|
|
showFlashMessage('error', 'Failed to confirm visit');
|
|
}
|
|
});
|
|
|
|
// Decline button handler
|
|
const declineBtn = item.querySelector('.decline-visit');
|
|
declineBtn?.addEventListener('click', async (event) => {
|
|
event.stopPropagation();
|
|
const visitId = event.target.dataset.id;
|
|
try {
|
|
const response = await fetch(`/api/v1/visits/${visitId}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${this.apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
visit: {
|
|
status: 'declined'
|
|
}
|
|
})
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to decline visit');
|
|
|
|
// Refresh visits list
|
|
this.fetchAndDisplayVisits();
|
|
showFlashMessage('notice', 'Visit declined successfully');
|
|
} catch (error) {
|
|
console.error('Error declining visit:', error);
|
|
showFlashMessage('error', 'Failed to decline visit');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Highlights a visit both in the panel and on the map
|
|
* @param {string} visitId - The ID of the visit to highlight
|
|
* @param {HTMLElement} item - The visit item element in the drawer
|
|
* @param {Array} coords - The coordinates [lat, lng] of the visit
|
|
*/
|
|
highlightVisit(visitId, item, coords) {
|
|
// Clear existing highlight
|
|
this.clearVisitHighlight();
|
|
|
|
// Store the current highlighted visit ID
|
|
this.highlightedVisitId = visitId;
|
|
|
|
// Highlight in the drawer panel
|
|
if (item) {
|
|
item.classList.add('visit-highlighted');
|
|
item.style.border = '2px solid #60a5fa';
|
|
item.style.boxShadow = '0 0 0 2px #60a5fa';
|
|
}
|
|
|
|
// Find and highlight the circle on the map
|
|
if (coords && !isNaN(coords[0]) && !isNaN(coords[1])) {
|
|
console.log(`Highlighting visit ID: ${visitId} at coordinates [${coords[0]}, ${coords[1]}]`);
|
|
|
|
// Create a Leaflet LatLng object from the coords
|
|
const targetLatLng = L.latLng(coords[0], coords[1]);
|
|
|
|
// Helper function to find and highlight circles that are very close to the coords
|
|
const findAndHighlightCircles = (layerGroup) => {
|
|
layerGroup.eachLayer(layer => {
|
|
if (layer instanceof L.Circle) {
|
|
// Calculate the distance between circle center and target coordinates
|
|
const distance = targetLatLng.distanceTo(layer.getLatLng());
|
|
|
|
// Use a small distance threshold (2 meters)
|
|
if (distance < 2) {
|
|
console.log(`Found matching circle at distance: ${distance.toFixed(2)}m`);
|
|
|
|
// Store original style for restoration
|
|
const originalStyle = {
|
|
color: layer.options.color,
|
|
weight: layer.options.weight,
|
|
fillOpacity: layer.options.fillOpacity
|
|
};
|
|
|
|
layer._originalStyle = originalStyle;
|
|
|
|
// Apply highlighting
|
|
layer.setStyle({
|
|
color: '#f59e0b', // Amber color for highlighting
|
|
weight: 4,
|
|
fillOpacity: 0.7
|
|
});
|
|
|
|
// Add to the tracked highlights
|
|
this.highlightedCircles.push(layer);
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
// Check in both layer groups
|
|
findAndHighlightCircles(this.visitCircles);
|
|
findAndHighlightCircles(this.confirmedVisitCircles);
|
|
|
|
console.log(`Found ${this.highlightedCircles.length} circles to highlight`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clears any existing visit highlight
|
|
*/
|
|
clearVisitHighlight() {
|
|
// Clear panel highlight
|
|
const highlightedItems = document.querySelectorAll('.visit-highlighted');
|
|
highlightedItems.forEach(el => {
|
|
el.classList.remove('visit-highlighted');
|
|
el.style.border = '';
|
|
el.style.boxShadow = '';
|
|
});
|
|
|
|
// Restore original circle styles for all highlighted circles
|
|
console.log(`Clearing ${this.highlightedCircles.length} highlighted circles`);
|
|
this.highlightedCircles.forEach(circle => {
|
|
if (circle && circle._originalStyle) {
|
|
circle.setStyle(circle._originalStyle);
|
|
} else if (circle) {
|
|
console.warn('Circle missing original style during cleanup');
|
|
}
|
|
});
|
|
|
|
// Clear the array of highlighted circles
|
|
this.highlightedCircles = [];
|
|
this.highlightedVisitId = null;
|
|
}
|
|
|
|
/**
|
|
* Fetches possible places for a visit and displays them in a popup
|
|
* @param {Object} visit - The visit object
|
|
*/
|
|
async fetchPossiblePlaces(visit) {
|
|
try {
|
|
// Close any existing popup before opening a new one
|
|
if (this.currentPopup) {
|
|
this.map.closePopup(this.currentPopup);
|
|
this.currentPopup = null;
|
|
}
|
|
|
|
// Find and highlight the corresponding visit item in the drawer
|
|
if (visit.id) {
|
|
const visitItem = document.querySelector(`.visit-item[data-id="${visit.id}"]`);
|
|
if (visitItem && visit.place?.latitude && visit.place?.longitude) {
|
|
this.highlightVisit(visit.id, visitItem, [visit.place.latitude, visit.place.longitude]);
|
|
}
|
|
}
|
|
|
|
const response = await fetch(`/api/v1/visits/${visit.id}/possible_places`, {
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'Authorization': `Bearer ${this.apiKey}`,
|
|
}
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to fetch possible places');
|
|
|
|
const possiblePlaces = await response.json();
|
|
|
|
// Format date and time
|
|
const startDate = new Date(visit.started_at);
|
|
const endDate = new Date(visit.ended_at);
|
|
const isSameDay = startDate.toDateString() === endDate.toDateString();
|
|
|
|
let dateTimeDisplay;
|
|
if (isSameDay) {
|
|
dateTimeDisplay = `
|
|
${startDate.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })},
|
|
${startDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} -
|
|
${endDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })}
|
|
`;
|
|
} else {
|
|
dateTimeDisplay = `
|
|
${startDate.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })},
|
|
${startDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} -
|
|
${endDate.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })},
|
|
${endDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })}
|
|
`;
|
|
}
|
|
|
|
// Format duration
|
|
const durationText = this.formatDuration(visit.duration * 60);
|
|
|
|
// Status with color coding
|
|
const statusColorClass = visit.status === 'confirmed' ? 'text-success' : 'text-warning';
|
|
|
|
// Create popup content with form and dropdown
|
|
const defaultName = visit.name;
|
|
const popupContent = `
|
|
<div style="min-width: 280px;">
|
|
<h3 class="text-base font-semibold mb-3">${dateTimeDisplay.trim()}</h3>
|
|
|
|
<div class="space-y-1 mb-4 text-sm">
|
|
<div>Duration: ${durationText}</div>
|
|
<div class="${statusColorClass} font-semibold">Status: ${visit.status.charAt(0).toUpperCase() + visit.status.slice(1)}</div>
|
|
<div class="text-xs opacity-60 font-mono">${visit.place.latitude}, ${visit.place.longitude}</div>
|
|
</div>
|
|
|
|
<form class="visit-name-form space-y-3" data-visit-id="${visit.id}">
|
|
<div class="form-control">
|
|
<label class="label">
|
|
<span class="label-text font-medium">Visit Name:</span>
|
|
</label>
|
|
<input type="text"
|
|
class="input input-bordered w-full"
|
|
value="${defaultName}"
|
|
placeholder="Enter visit name">
|
|
</div>
|
|
|
|
<div class="form-control">
|
|
<label class="label">
|
|
<span class="label-text font-medium">Location:</span>
|
|
</label>
|
|
<select class="select select-bordered w-full" name="place">
|
|
${possiblePlaces.length > 0 ? possiblePlaces.map(place => `
|
|
<option value="${place.id}" ${place.id === visit.place.id ? 'selected' : ''}>
|
|
${place.name}
|
|
</option>
|
|
`).join('') : `
|
|
<option value="${visit.place.id}" selected>
|
|
${visit.place.name || 'Current Location'}
|
|
</option>
|
|
`}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-3 gap-2">
|
|
<button type="submit" class="btn btn-primary btn-sm">
|
|
Save
|
|
</button>
|
|
${visit.status !== 'confirmed' ? `
|
|
<button type="button" class="btn btn-success btn-sm confirm-visit" data-id="${visit.id}">
|
|
Confirm
|
|
</button>
|
|
<button type="button" class="btn btn-error btn-sm decline-visit" data-id="${visit.id}">
|
|
Decline
|
|
</button>
|
|
` : '<div class="col-span-2"></div>'}
|
|
</div>
|
|
|
|
<button type="button" class="btn btn-outline btn-error btn-sm w-full delete-visit" data-id="${visit.id}">
|
|
Delete Visit
|
|
</button>
|
|
</form>
|
|
</div>
|
|
`;
|
|
|
|
// Create and store the popup
|
|
const popup = L.popup({
|
|
closeButton: true,
|
|
closeOnClick: true,
|
|
autoClose: true,
|
|
closeOnEscapeKey: true,
|
|
maxWidth: 420, // Set maximum width
|
|
minWidth: 320, // Set minimum width
|
|
className: 'visit-popup' // Add custom class for additional styling
|
|
})
|
|
.setLatLng([visit.place.latitude, visit.place.longitude])
|
|
.setContent(popupContent);
|
|
|
|
// Store the current popup
|
|
this.currentPopup = popup;
|
|
|
|
// Open the popup
|
|
popup.openOn(this.map);
|
|
|
|
// Add form submit handler
|
|
this.addPopupFormEventListeners(visit);
|
|
} catch (error) {
|
|
console.error('Error fetching possible places:', error);
|
|
showFlashMessage('error', 'Failed to load possible places');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds event listeners to the popup form
|
|
* @param {Object} visit - The visit object
|
|
*/
|
|
addPopupFormEventListeners(visit) {
|
|
const form = document.querySelector(`.visit-name-form[data-visit-id="${visit.id}"]`);
|
|
if (form) {
|
|
form.addEventListener('submit', async (event) => {
|
|
event.preventDefault(); // Prevent form submission
|
|
event.stopPropagation(); // Stop event bubbling
|
|
const newName = event.target.querySelector('input').value;
|
|
const selectedPlaceId = event.target.querySelector('select[name="place"]').value;
|
|
|
|
// Validate that we have a valid place_id
|
|
if (!selectedPlaceId || selectedPlaceId === '') {
|
|
showFlashMessage('error', 'Please select a valid location');
|
|
return;
|
|
}
|
|
|
|
// Get the selected place name from the dropdown
|
|
const selectedOption = event.target.querySelector(`select[name="place"] option[value="${selectedPlaceId}"]`);
|
|
const selectedPlaceName = selectedOption ? selectedOption.textContent.trim() : '';
|
|
|
|
console.log('Selected new place:', selectedPlaceName);
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/visits/${visit.id}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${this.apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
visit: {
|
|
name: newName,
|
|
place_id: selectedPlaceId
|
|
}
|
|
})
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to update visit');
|
|
|
|
// Get the updated visit data from the response
|
|
const updatedVisit = await response.json();
|
|
|
|
// Update the local visit object with the latest data
|
|
// This ensures that if the popup is opened again, it will show the updated values
|
|
visit.name = updatedVisit.name || newName;
|
|
visit.place = updatedVisit.place;
|
|
|
|
// Use the selected place name for the update
|
|
const updatedName = selectedPlaceName || newName;
|
|
console.log('Updating visit name in drawer to:', updatedName);
|
|
|
|
// Update the visit name in the drawer panel
|
|
const drawerVisitItem = document.querySelector(`.drawer .visit-item[data-id="${visit.id}"]`);
|
|
if (drawerVisitItem) {
|
|
const nameElement = drawerVisitItem.querySelector('.font-semibold');
|
|
if (nameElement) {
|
|
console.log('Previous name in drawer:', nameElement.textContent);
|
|
nameElement.textContent = updatedName;
|
|
|
|
// Add a highlight effect to make the change visible
|
|
nameElement.style.backgroundColor = 'rgba(255, 255, 0, 0.3)';
|
|
setTimeout(() => {
|
|
nameElement.style.backgroundColor = '';
|
|
}, 2000);
|
|
|
|
console.log('Updated name in drawer to:', nameElement.textContent);
|
|
}
|
|
}
|
|
|
|
// Close the popup
|
|
this.map.closePopup(this.currentPopup);
|
|
this.currentPopup = null;
|
|
showFlashMessage('notice', 'Visit updated successfully');
|
|
} catch (error) {
|
|
console.error('Error updating visit:', error);
|
|
showFlashMessage('error', 'Failed to update visit');
|
|
}
|
|
});
|
|
|
|
// Add event listeners for confirm and decline buttons
|
|
const confirmBtn = form.querySelector('.confirm-visit');
|
|
const declineBtn = form.querySelector('.decline-visit');
|
|
const deleteBtn = form.querySelector('.delete-visit');
|
|
|
|
confirmBtn?.addEventListener('click', (event) => this.handleStatusChange(event, visit.id, 'confirmed'));
|
|
declineBtn?.addEventListener('click', (event) => this.handleStatusChange(event, visit.id, 'declined'));
|
|
deleteBtn?.addEventListener('click', (event) => this.handleDeleteVisit(event, visit.id));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles status change for a visit (confirm/decline)
|
|
* @param {Event} event - The click event
|
|
* @param {string} visitId - The visit ID
|
|
* @param {string} status - The new status ('confirmed' or 'declined')
|
|
*/
|
|
async handleStatusChange(event, visitId, status) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
try {
|
|
const response = await fetch(`/api/v1/visits/${visitId}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${this.apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
visit: {
|
|
status: status
|
|
}
|
|
})
|
|
});
|
|
|
|
if (!response.ok) throw new Error(`Failed to ${status} visit`);
|
|
|
|
if (this.currentPopup) {
|
|
this.map.closePopup(this.currentPopup);
|
|
this.currentPopup = null;
|
|
}
|
|
|
|
this.fetchAndDisplayVisits();
|
|
showFlashMessage('notice', `Visit ${status}d successfully`);
|
|
} catch (error) {
|
|
console.error(`Error ${status}ing visit:`, error);
|
|
showFlashMessage('error', `Failed to ${status} visit`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles deletion of a visit with confirmation
|
|
* @param {Event} event - The click event
|
|
* @param {string} visitId - The visit ID to delete
|
|
*/
|
|
async handleDeleteVisit(event, visitId) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
// Show confirmation dialog
|
|
const confirmDelete = confirm('Are you sure you want to delete this visit? This action cannot be undone.');
|
|
|
|
if (!confirmDelete) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/visits/${visitId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${this.apiKey}`,
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
// Close the popup
|
|
if (this.currentPopup) {
|
|
this.map.closePopup(this.currentPopup);
|
|
this.currentPopup = null;
|
|
}
|
|
|
|
// Refresh the visits list
|
|
this.fetchAndDisplayVisits();
|
|
showFlashMessage('notice', 'Visit deleted successfully');
|
|
} else {
|
|
const errorData = await response.json();
|
|
const errorMessage = errorData.error || 'Failed to delete visit';
|
|
showFlashMessage('error', errorMessage);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting visit:', error);
|
|
showFlashMessage('error', 'Failed to delete visit');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Truncates text to a specified length and adds ellipsis if needed
|
|
* @param {string} text - The text to truncate
|
|
* @param {number} maxLength - The maximum length
|
|
* @returns {string} Truncated text
|
|
*/
|
|
truncateText(text, maxLength) {
|
|
if (text.length <= maxLength) return text;
|
|
return text.substring(0, maxLength) + '...';
|
|
}
|
|
|
|
/**
|
|
* Gets the visits layer group for adding to the map controls
|
|
* @returns {L.LayerGroup} The visits layer group
|
|
*/
|
|
getVisitCirclesLayer() {
|
|
return this.visitCircles;
|
|
}
|
|
|
|
/**
|
|
* Gets the confirmed visits layer group that's always visible
|
|
* @returns {L.LayerGroup} The confirmed visits layer group
|
|
*/
|
|
getConfirmedVisitCirclesLayer() {
|
|
return this.confirmedVisitCircles;
|
|
}
|
|
}
|