import { SelectionLayer } from 'maps_maplibre/layers/selection_layer' import { SelectedPointsLayer } from 'maps_maplibre/layers/selected_points_layer' import { pointsToGeoJSON } from 'maps_maplibre/utils/geojson_transformers' import { VisitCard } from 'maps_maplibre/components/visit_card' import { Toast } from 'maps_maplibre/components/toast' /** * Manages area selection and bulk operations for Maps V2 * Handles selection mode, visit cards, and bulk actions (merge, confirm, decline) */ export class AreaSelectionManager { constructor(controller) { this.controller = controller this.map = controller.map this.api = controller.api this.selectionLayer = null this.selectedPointsLayer = null this.selectedVisits = [] this.selectedVisitIds = new Set() } /** * Start area selection mode */ async startSelectArea() { console.log('[Maps V2] Starting area selection mode') // Initialize selection layer if not exists if (!this.selectionLayer) { this.selectionLayer = new SelectionLayer(this.map, { visible: true, onSelectionComplete: this.handleAreaSelected.bind(this) }) this.selectionLayer.add({ type: 'FeatureCollection', features: [] }) console.log('[Maps V2] Selection layer initialized') } // Initialize selected points layer if not exists if (!this.selectedPointsLayer) { this.selectedPointsLayer = new SelectedPointsLayer(this.map, { visible: true }) this.selectedPointsLayer.add({ type: 'FeatureCollection', features: [] }) console.log('[Maps V2] Selected points layer initialized') } // Enable selection mode this.selectionLayer.enableSelectionMode() // Update UI - replace Select Area button with Cancel Selection button if (this.controller.hasSelectAreaButtonTarget) { this.controller.selectAreaButtonTarget.innerHTML = ` Cancel Selection ` this.controller.selectAreaButtonTarget.dataset.action = 'click->maps--maplibre#cancelAreaSelection' } Toast.info('Draw a rectangle on the map to select points') } /** * Handle area selection completion */ async handleAreaSelected(bounds) { console.log('[Maps V2] Area selected:', bounds) try { Toast.info('Fetching data in selected area...') const [points, visits] = await Promise.all([ this.api.fetchPointsInArea({ start_at: this.controller.startDateValue, end_at: this.controller.endDateValue, min_longitude: bounds.minLng, max_longitude: bounds.maxLng, min_latitude: bounds.minLat, max_latitude: bounds.maxLat }), this.api.fetchVisitsInArea({ start_at: this.controller.startDateValue, end_at: this.controller.endDateValue, sw_lat: bounds.minLat, sw_lng: bounds.minLng, ne_lat: bounds.maxLat, ne_lng: bounds.maxLng }) ]) console.log('[Maps V2] Found', points.length, 'points and', visits.length, 'visits in area') if (points.length === 0 && visits.length === 0) { Toast.info('No data found in selected area') this.cancelAreaSelection() return } // Convert points to GeoJSON and display if (points.length > 0) { const geojson = pointsToGeoJSON(points) this.selectedPointsLayer.updateSelectedPoints(geojson) this.selectedPointsLayer.show() } // Display visits in side panel and on map if (visits.length > 0) { this.displaySelectedVisits(visits) } // Update UI - show action buttons if (this.controller.hasSelectionActionsTarget) { this.controller.selectionActionsTarget.classList.remove('hidden') } // Update delete button text with count if (this.controller.hasDeleteButtonTextTarget) { this.controller.deleteButtonTextTarget.textContent = `Delete ${points.length} Point${points.length === 1 ? '' : 's'}` } // Disable selection mode this.selectionLayer.disableSelectionMode() const messages = [] if (points.length > 0) messages.push(`${points.length} point${points.length === 1 ? '' : 's'}`) if (visits.length > 0) messages.push(`${visits.length} visit${visits.length === 1 ? '' : 's'}`) Toast.success(`Selected ${messages.join(' and ')}`) } catch (error) { console.error('[Maps V2] Failed to fetch data in area:', error) Toast.error('Failed to fetch data in selected area') this.cancelAreaSelection() } } /** * Display selected visits in side panel */ displaySelectedVisits(visits) { if (!this.controller.hasSelectedVisitsContainerTarget) return this.selectedVisits = visits this.selectedVisitIds = new Set() const cardsHTML = visits.map(visit => VisitCard.create(visit, { isSelected: false }) ).join('') this.controller.selectedVisitsContainerTarget.innerHTML = `

Visits in Area (${visits.length})

${cardsHTML}
` this.controller.selectedVisitsContainerTarget.classList.remove('hidden') this.attachVisitCardListeners() requestAnimationFrame(() => { this.updateBulkActions() }) } /** * Attach event listeners to visit cards */ attachVisitCardListeners() { this.controller.element.querySelectorAll('[data-visit-select]').forEach(checkbox => { checkbox.addEventListener('change', (e) => { const visitId = parseInt(e.target.dataset.visitSelect) if (e.target.checked) { this.selectedVisitIds.add(visitId) } else { this.selectedVisitIds.delete(visitId) } this.updateBulkActions() }) }) this.controller.element.querySelectorAll('[data-visit-confirm]').forEach(btn => { btn.addEventListener('click', async (e) => { const visitId = parseInt(e.currentTarget.dataset.visitConfirm) await this.confirmVisit(visitId) }) }) this.controller.element.querySelectorAll('[data-visit-decline]').forEach(btn => { btn.addEventListener('click', async (e) => { const visitId = parseInt(e.currentTarget.dataset.visitDecline) await this.declineVisit(visitId) }) }) } /** * Update bulk action buttons visibility and attach listeners */ updateBulkActions() { const selectedCount = this.selectedVisitIds.size const existingBulkActions = this.controller.element.querySelectorAll('.bulk-actions-inline') existingBulkActions.forEach(el => el.remove()) if (selectedCount >= 2) { const selectedVisitCards = Array.from(this.controller.element.querySelectorAll('.visit-card')) .filter(card => { const visitId = parseInt(card.dataset.visitId) return this.selectedVisitIds.has(visitId) }) if (selectedVisitCards.length > 0) { const lastSelectedCard = selectedVisitCards[selectedVisitCards.length - 1] const bulkActionsDiv = document.createElement('div') bulkActionsDiv.className = 'bulk-actions-inline mb-2' bulkActionsDiv.innerHTML = `
${selectedCount} visit${selectedCount === 1 ? '' : 's'} selected
` lastSelectedCard.insertAdjacentElement('afterend', bulkActionsDiv) const mergeBtn = bulkActionsDiv.querySelector('[data-bulk-merge]') const confirmBtn = bulkActionsDiv.querySelector('[data-bulk-confirm]') const declineBtn = bulkActionsDiv.querySelector('[data-bulk-decline]') if (mergeBtn) mergeBtn.addEventListener('click', () => this.bulkMergeVisits()) if (confirmBtn) confirmBtn.addEventListener('click', () => this.bulkConfirmVisits()) if (declineBtn) declineBtn.addEventListener('click', () => this.bulkDeclineVisits()) } } } /** * Confirm a single visit */ async confirmVisit(visitId) { try { await this.api.updateVisitStatus(visitId, 'confirmed') Toast.success('Visit confirmed') await this.refreshSelectedVisits() } catch (error) { console.error('[Maps V2] Failed to confirm visit:', error) Toast.error('Failed to confirm visit') } } /** * Decline a single visit */ async declineVisit(visitId) { try { await this.api.updateVisitStatus(visitId, 'declined') Toast.success('Visit declined') await this.refreshSelectedVisits() } catch (error) { console.error('[Maps V2] Failed to decline visit:', error) Toast.error('Failed to decline visit') } } /** * Bulk merge selected visits */ async bulkMergeVisits() { const visitIds = Array.from(this.selectedVisitIds) if (visitIds.length < 2) { Toast.error('Select at least 2 visits to merge') return } if (!confirm(`Merge ${visitIds.length} visits into one?`)) { return } try { Toast.info('Merging visits...') const mergedVisit = await this.api.mergeVisits(visitIds) Toast.success('Visits merged successfully') this.selectedVisitIds.clear() this.replaceVisitsWithMerged(visitIds, mergedVisit) this.updateBulkActions() } catch (error) { console.error('[Maps V2] Failed to merge visits:', error) Toast.error('Failed to merge visits') } } /** * Bulk confirm selected visits */ async bulkConfirmVisits() { const visitIds = Array.from(this.selectedVisitIds) try { Toast.info('Confirming visits...') await this.api.bulkUpdateVisits(visitIds, 'confirmed') Toast.success(`Confirmed ${visitIds.length} visits`) this.selectedVisitIds.clear() await this.refreshSelectedVisits() } catch (error) { console.error('[Maps V2] Failed to confirm visits:', error) Toast.error('Failed to confirm visits') } } /** * Bulk decline selected visits */ async bulkDeclineVisits() { const visitIds = Array.from(this.selectedVisitIds) if (!confirm(`Decline ${visitIds.length} visits?`)) { return } try { Toast.info('Declining visits...') await this.api.bulkUpdateVisits(visitIds, 'declined') Toast.success(`Declined ${visitIds.length} visits`) this.selectedVisitIds.clear() await this.refreshSelectedVisits() } catch (error) { console.error('[Maps V2] Failed to decline visits:', error) Toast.error('Failed to decline visits') } } /** * Replace merged visit cards with the new merged visit */ replaceVisitsWithMerged(oldVisitIds, mergedVisit) { const container = this.controller.element.querySelector('.selected-visits-list') if (!container) return const mergedStartTime = new Date(mergedVisit.started_at).getTime() const allCards = Array.from(container.querySelectorAll('.visit-card')) let insertBeforeCard = null for (const card of allCards) { const cardId = parseInt(card.dataset.visitId) if (oldVisitIds.includes(cardId)) continue const cardVisit = this.selectedVisits.find(v => v.id === cardId) if (cardVisit) { const cardStartTime = new Date(cardVisit.started_at).getTime() if (cardStartTime > mergedStartTime) { insertBeforeCard = card break } } } oldVisitIds.forEach(id => { const card = this.controller.element.querySelector(`.visit-card[data-visit-id="${id}"]`) if (card) card.remove() }) this.selectedVisits = this.selectedVisits.filter(v => !oldVisitIds.includes(v.id)) this.selectedVisits.push(mergedVisit) this.selectedVisits.sort((a, b) => new Date(a.started_at) - new Date(b.started_at)) const newCardHTML = VisitCard.create(mergedVisit, { isSelected: false }) if (insertBeforeCard) { insertBeforeCard.insertAdjacentHTML('beforebegin', newCardHTML) } else { container.insertAdjacentHTML('beforeend', newCardHTML) } const header = container.querySelector('h3') if (header) { header.textContent = `Visits in Area (${this.selectedVisits.length})` } this.attachVisitCardListeners() } /** * Refresh selected visits after changes */ async refreshSelectedVisits() { const bounds = this.selectionLayer.currentRect if (!bounds) return try { const visits = await this.api.fetchVisitsInArea({ start_at: this.controller.startDateValue, end_at: this.controller.endDateValue, sw_lat: bounds.start.lat < bounds.end.lat ? bounds.start.lat : bounds.end.lat, sw_lng: bounds.start.lng < bounds.end.lng ? bounds.start.lng : bounds.end.lng, ne_lat: bounds.start.lat > bounds.end.lat ? bounds.start.lat : bounds.end.lat, ne_lng: bounds.start.lng > bounds.end.lng ? bounds.start.lng : bounds.end.lng }) this.displaySelectedVisits(visits) } catch (error) { console.error('[Maps V2] Failed to refresh visits:', error) } } /** * Cancel area selection */ cancelAreaSelection() { console.log('[Maps V2] Cancelling area selection') if (this.selectionLayer) { this.selectionLayer.disableSelectionMode() this.selectionLayer.clearSelection() } if (this.selectedPointsLayer) { this.selectedPointsLayer.clearSelection() } if (this.controller.hasSelectedVisitsContainerTarget) { this.controller.selectedVisitsContainerTarget.classList.add('hidden') this.controller.selectedVisitsContainerTarget.innerHTML = '' } if (this.controller.hasSelectedVisitsBulkActionsTarget) { this.controller.selectedVisitsBulkActionsTarget.classList.add('hidden') } this.selectedVisits = [] this.selectedVisitIds = new Set() if (this.controller.hasSelectAreaButtonTarget) { this.controller.selectAreaButtonTarget.innerHTML = ` Select Area ` this.controller.selectAreaButtonTarget.classList.remove('btn-error') this.controller.selectAreaButtonTarget.classList.add('btn', 'btn-outline') this.controller.selectAreaButtonTarget.dataset.action = 'click->maps--maplibre#startSelectArea' } if (this.controller.hasSelectionActionsTarget) { this.controller.selectionActionsTarget.classList.add('hidden') } Toast.info('Selection cancelled') } /** * Delete selected points */ async deleteSelectedPoints() { const pointCount = this.selectedPointsLayer.getCount() const pointIds = this.selectedPointsLayer.getSelectedPointIds() if (pointIds.length === 0) { Toast.error('No points selected') return } const confirmed = confirm( `Are you sure you want to delete ${pointCount} point${pointCount === 1 ? '' : 's'}? This action cannot be undone.` ) if (!confirmed) return console.log('[Maps V2] Deleting', pointIds.length, 'points') try { Toast.info('Deleting points...') const result = await this.api.bulkDeletePoints(pointIds) console.log('[Maps V2] Deleted', result.count, 'points') this.cancelAreaSelection() await this.controller.loadMapData({ showLoading: false, fitBounds: false, showToast: false }) Toast.success(`Deleted ${result.count} point${result.count === 1 ? '' : 's'}`) } catch (error) { console.error('[Maps V2] Failed to delete points:', error) Toast.error('Failed to delete points. Please try again.') } } }