From c00bd2e38771c438dab87dda1a7f1a5787d60f50 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 4 Mar 2025 21:50:46 +0100 Subject: [PATCH] Extract visits code from maps controller --- CHANGELOG.md | 8 + app/javascript/controllers/maps_controller.js | 509 +--------------- app/javascript/maps/visits.js | 575 ++++++++++++++++++ 3 files changed, 590 insertions(+), 502 deletions(-) create mode 100644 app/javascript/maps/visits.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fbd7518..b7501b09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). # 0.24.2 - 2025-02-24 +TODO: + +- [ ] Allow user to see all suggested visits in specific area on the map regardless of time period. (clustering?) #921 +- [ ] Allow user to merge visits #909 +- [ ] Accepted Visits are not remembered #848 +- [ ] Flash z-index is under visits drawer +- [ ] There are still duplicates in the visits places? + ## Added - Status field to the User model. Inactive users are now being restricted from accessing some of the functionality, which is mostly about writing data to the database. Reading is remaining unrestricted. diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 92227c8b..c5a798e6 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -15,6 +15,7 @@ import { fetchAndDrawAreas, handleAreaCreated } from "../maps/areas"; import { showFlashMessage, fetchAndDisplayPhotos } from "../maps/helpers"; import { countryCodesMap } from "../maps/country_codes"; +import { VisitsManager } from "../maps/visits"; import "leaflet-draw"; import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war"; @@ -29,8 +30,6 @@ export default class extends BaseController { layerControl = null; visitedCitiesCache = new Map(); trackedMonthsCache = null; - drawerOpen = false; - visitCircles = L.layerGroup(); currentPopup = null; connect() { @@ -119,6 +118,9 @@ export default class extends BaseController { this.addSettingsButton(); } + // Initialize the visits manager + this.visitsManager = new VisitsManager(this.map, this.apiKey); + // Initialize layers for the layer control const controlsLayer = { Points: this.markersLayer, @@ -257,11 +259,11 @@ export default class extends BaseController { // Start monitoring this.tileMonitor.startMonitoring(); - // Add the drawer button - this.addDrawerButton(); + // Add the drawer button for visits + this.visitsManager.addDrawerButton(); // Fetch and display visits when map loads - this.fetchAndDisplayVisits(); + this.visitsManager.fetchAndDisplayVisits(); } disconnect() { @@ -1337,502 +1339,5 @@ export default class extends BaseController { container.innerHTML = html; } - - 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'; - } - - addDrawerButton() { - const DrawerControl = L.Control.extend({ - onAdd: (map) => { - const button = L.DomUtil.create('button', 'leaflet-control-button drawer-button'); - button.innerHTML = '⬅️'; // Left arrow icon - button.style.width = '48px'; - button.style.height = '48px'; - button.style.border = 'none'; - button.style.cursor = 'pointer'; - button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; - button.style.backgroundColor = 'white'; - button.style.borderRadius = '4px'; - button.style.padding = '0'; - button.style.lineHeight = '48px'; - button.style.fontSize = '18px'; - button.style.textAlign = 'center'; - - L.DomEvent.disableClickPropagation(button); - L.DomEvent.on(button, 'click', () => { - this.toggleDrawer(); - }); - - return button; - } - }); - - this.map.addControl(new DrawerControl({ position: 'topright' })); - } - - toggleDrawer() { - this.drawerOpen = !this.drawerOpen; - - let drawer = document.querySelector('.leaflet-drawer'); - if (!drawer) { - drawer = this.createDrawer(); - } - - drawer.classList.toggle('open'); - - const drawerButton = document.querySelector('.drawer-button'); - if (drawerButton) { - drawerButton.innerHTML = this.drawerOpen ? '➡️' : '⬅️'; - } - - const controls = document.querySelectorAll('.leaflet-control-layers, .toggle-panel-button, .leaflet-right-panel, .drawer-button'); - controls.forEach(control => { - control.classList.toggle('controls-shifted'); - }); - - // Update the drawer content if it's being opened - if (this.drawerOpen) { - this.fetchAndDisplayVisits(); - } - } - - createDrawer() { - const drawer = document.createElement('div'); - drawer.id = 'visits-drawer'; - drawer.className = 'fixed top-0 right-0 h-full w-64 bg-base-100 shadow-lg transform translate-x-full transition-transform duration-300 ease-in-out z-50 overflow-y-auto leaflet-drawer'; - - // Add styles to make the drawer scrollable - drawer.style.overflowY = 'auto'; - drawer.style.maxHeight = '100vh'; - - drawer.innerHTML = ` -
-

Recent Visits

-
-

Loading visits...

-
-
- `; - - // 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; - } - - async fetchAndDisplayVisits() { - try { - // 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:', 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) { - throw new Error('Network response was not ok'); - } - - const visits = await response.json(); - this.displayVisits(visits); - } catch (error) { - console.error('Error fetching visits:', error); - const container = document.getElementById('visits-list'); - if (container) { - container.innerHTML = '

Error loading visits

'; - } - } - } - - displayVisits(visits) { - const container = document.getElementById('visits-list'); - if (!container) return; - - if (!visits || visits.length === 0) { - container.innerHTML = '

No visits found in selected timeframe

'; - return; - } - - // Clear existing visit circles - this.visitCircles.clearLayers(); - - // Draw circles for all visits - visits - .filter(visit => visit.status !== 'declined') - .forEach(visit => { - if (visit.place?.latitude && visit.place?.longitude) { - const isSuggested = visit.status === 'suggested'; - 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.4 : 0.6, - radius: 100, - weight: 2, - interactive: true, - bubblingMouseEvents: false, - pane: 'visitsPane', - dashArray: isSuggested ? '4' : null // Dotted border for suggested - }); - - // Add the circle to the map - this.visitCircles.addLayer(circle); - - // Fetch possible places for the visit - const fetchPossiblePlaces = async () => { - try { - // Close any existing popup before opening a new one - if (this.currentPopup) { - this.map.closePopup(this.currentPopup); - this.currentPopup = null; - } - - 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(); - - // Create popup content with form and dropdown - const defaultName = visit.name; - const popupContent = ` -
-
-
- -
-
- -
-
- - ${visit.status !== 'confirmed' ? ` - - - ` : ''} -
-
-
- `; - - // Create and store the popup - const popup = L.popup({ - closeButton: true, - closeOnClick: false, - autoClose: false, - maxWidth: 300, // Set maximum width - minWidth: 200 // Set minimum width - }) - .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 - 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; - - // 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(popup); - this.currentPopup = null; - showFlashMessage('notice', 'Visit updated successfully'); - } catch (error) { - console.error('Error updating visit:', error); - showFlashMessage('error', 'Failed to update visit'); - } - }); - - // Unified handler for confirm/decline - const handleStatusChange = async (event, status) => { - event.preventDefault(); - event.stopPropagation(); - 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: { - status: status - } - }) - }); - - if (!response.ok) throw new Error(`Failed to ${status} visit`); - - 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`); - } - }; - - // Add event listeners for confirm and decline buttons - const confirmBtn = form.querySelector('.confirm-visit'); - const declineBtn = form.querySelector('.decline-visit'); - - confirmBtn?.addEventListener('click', (event) => handleStatusChange(event, 'confirmed')); - declineBtn?.addEventListener('click', (event) => handleStatusChange(event, 'declined')); - } - } catch (error) { - console.error('Error fetching possible places:', error); - showFlashMessage('error', 'Failed to load possible places'); - } - }; - - // Attach click event to the circle - circle.on('click', fetchPossiblePlaces); - } - }); - - const html = 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: 'long', 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: 'long', day: 'numeric' })}, - ${startDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} - - ${endDate.toLocaleDateString(undefined, { month: 'long', 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 ` -
-
${this.truncateText(visit.name, 30)}
-
- ${timeDisplay.trim()} - (${durationText}) -
- ${visit.place?.city ? `
${visit.place.city}, ${visit.place.country}
` : ''} - ${visit.status !== 'confirmed' ? ` -
- - -
- ` : ''} -
- `; - }).join(''); - - container.innerHTML = html; - - // Add the circles layer to the map - this.visitCircles.addTo(this.map); - - // Add click handlers to visit items and buttons - const visitItems = container.querySelectorAll('.visit-item'); - visitItems.forEach(item => { - // Location click handler - item.addEventListener('click', (event) => { - // Don't trigger if clicking on buttons - if (event.target.classList.contains('btn')) return; - - const lat = parseFloat(item.dataset.lat); - const lng = parseFloat(item.dataset.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'); - } - }); - }); - } - - truncateText(text, maxLength) { - if (text.length <= maxLength) return text; - return text.substring(0, maxLength) + '...'; - } } diff --git a/app/javascript/maps/visits.js b/app/javascript/maps/visits.js new file mode 100644 index 00000000..64c5a55c --- /dev/null +++ b/app/javascript/maps/visits.js @@ -0,0 +1,575 @@ +import L from "leaflet"; +import { showFlashMessage } from "./helpers"; + +/** + * Manages visits functionality including displaying, fetching, and interacting with visits + */ +export class VisitsManager { + constructor(map, apiKey) { + this.map = map; + this.apiKey = apiKey; + this.visitCircles = L.layerGroup(); + this.currentPopup = null; + this.drawerOpen = false; + } + + /** + * 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'; + } + + /** + * Adds a button to toggle the visits drawer + */ + addDrawerButton() { + const DrawerControl = L.Control.extend({ + onAdd: (map) => { + const button = L.DomUtil.create('button', 'leaflet-control-button drawer-button'); + button.innerHTML = '⬅️'; // Left arrow icon + button.style.width = '48px'; + button.style.height = '48px'; + button.style.border = 'none'; + button.style.cursor = 'pointer'; + button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; + button.style.backgroundColor = 'white'; + button.style.borderRadius = '4px'; + button.style.padding = '0'; + button.style.lineHeight = '48px'; + button.style.fontSize = '18px'; + button.style.textAlign = 'center'; + + L.DomEvent.disableClickPropagation(button); + L.DomEvent.on(button, 'click', () => { + this.toggleDrawer(); + }); + + return button; + } + }); + + this.map.addControl(new DrawerControl({ position: 'topright' })); + } + + /** + * Toggles the visibility of the visits drawer + */ + toggleDrawer() { + this.drawerOpen = !this.drawerOpen; + + let drawer = document.querySelector('.leaflet-drawer'); + if (!drawer) { + drawer = this.createDrawer(); + } + + drawer.classList.toggle('open'); + + const drawerButton = document.querySelector('.drawer-button'); + if (drawerButton) { + drawerButton.innerHTML = this.drawerOpen ? '➡️' : '⬅️'; + } + + const controls = document.querySelectorAll('.leaflet-control-layers, .toggle-panel-button, .leaflet-right-panel, .drawer-button'); + controls.forEach(control => { + control.classList.toggle('controls-shifted'); + }); + + // Update the drawer content if it's being opened + if (this.drawerOpen) { + this.fetchAndDisplayVisits(); + } + } + + /** + * 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 = 'fixed top-0 right-0 h-full w-64 bg-base-100 shadow-lg transform translate-x-full transition-transform duration-300 ease-in-out z-50 overflow-y-auto leaflet-drawer'; + + // Add styles to make the drawer scrollable + drawer.style.overflowY = 'auto'; + drawer.style.maxHeight = '100vh'; + + drawer.innerHTML = ` +
+

Recent Visits

+
+

Loading visits...

+
+
+ `; + + // 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 { + // 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:', 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) { + throw new Error('Network response was not ok'); + } + + const visits = await response.json(); + this.displayVisits(visits); + } catch (error) { + console.error('Error fetching visits:', error); + const container = document.getElementById('visits-list'); + if (container) { + container.innerHTML = '

Error loading visits

'; + } + } + } + + /** + * Displays visits on the map and in the drawer + * @param {Array} visits - Array of visit objects + */ + displayVisits(visits) { + const container = document.getElementById('visits-list'); + if (!container) return; + + if (!visits || visits.length === 0) { + container.innerHTML = '

No visits found in selected timeframe

'; + return; + } + + // Clear existing visit circles + this.visitCircles.clearLayers(); + + // Draw circles for all visits + visits + .filter(visit => visit.status !== 'declined') + .forEach(visit => { + if (visit.place?.latitude && visit.place?.longitude) { + const isSuggested = visit.status === 'suggested'; + 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.4 : 0.6, + radius: 100, + weight: 2, + interactive: true, + bubblingMouseEvents: false, + pane: 'visitsPane', + dashArray: isSuggested ? '4' : null // Dotted border for suggested + }); + + // Add the circle to the map + this.visitCircles.addLayer(circle); + + // Attach click event to the circle + circle.on('click', () => this.fetchPossiblePlaces(visit)); + } + }); + + const html = 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: 'long', 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: 'long', day: 'numeric' })}, + ${startDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} - + ${endDate.toLocaleDateString(undefined, { month: 'long', 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 ` +
+
${this.truncateText(visit.name, 30)}
+
+ ${timeDisplay.trim()} + (${durationText}) +
+ ${visit.place?.city ? `
${visit.place.city}, ${visit.place.country}
` : ''} + ${visit.status !== 'confirmed' ? ` +
+ + +
+ ` : ''} +
+ `; + }).join(''); + + container.innerHTML = html; + + // Add the circles layer to the map + this.visitCircles.addTo(this.map); + + // Add click handlers to visit items and buttons + this.addVisitItemEventListeners(container); + } + + /** + * 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'); + visitItems.forEach(item => { + // Location click handler + item.addEventListener('click', (event) => { + // Don't trigger if clicking on buttons + if (event.target.classList.contains('btn')) return; + + const lat = parseFloat(item.dataset.lat); + const lng = parseFloat(item.dataset.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'); + } + }); + }); + } + + /** + * 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; + } + + 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(); + + // Create popup content with form and dropdown + const defaultName = visit.name; + const popupContent = ` +
+
+
+ +
+
+ +
+
+ + ${visit.status !== 'confirmed' ? ` + + + ` : ''} +
+
+
+ `; + + // Create and store the popup + const popup = L.popup({ + closeButton: true, + closeOnClick: false, + autoClose: false, + maxWidth: 300, // Set maximum width + minWidth: 200 // Set minimum width + }) + .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; + + // 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'); + + confirmBtn?.addEventListener('click', (event) => this.handleStatusChange(event, visit.id, 'confirmed')); + declineBtn?.addEventListener('click', (event) => this.handleStatusChange(event, visit.id, 'declined')); + } + } + + /** + * 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`); + } + } + + /** + * 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; + } +}