diff --git a/map/app/controllers/cutsController.js b/map/app/controllers/cutsController.js
index fa54e4f..bfbc552 100644
--- a/map/app/controllers/cutsController.js
+++ b/map/app/controllers/cutsController.js
@@ -1,6 +1,7 @@
const nocodbService = require('../services/nocodb');
const logger = require('../utils/logger');
const config = require('../config');
+const spatialUtils = require('../utils/spatial');
class CutsController {
/**
@@ -358,6 +359,272 @@ class CutsController {
});
}
}
+
+ /**
+ * Get all locations within a cut boundary - admin only
+ */
+ async getLocationsInCut(req, res) {
+ try {
+ const { id } = req.params;
+ const { isAdmin } = req.user || {};
+
+ if (!isAdmin) {
+ return res.status(403).json({ error: 'Admin access required' });
+ }
+
+ // Get the cut
+ const cut = await nocodbService.getById(config.CUTS_TABLE_ID, id);
+ if (!cut) {
+ return res.status(404).json({ error: 'Cut not found' });
+ }
+
+ // Get all locations
+ const locationsResponse = await nocodbService.getAll(config.LOCATIONS_TABLE_ID);
+ if (!locationsResponse || !locationsResponse.list) {
+ return res.json({ locations: [], statistics: { total_locations: 0 } });
+ }
+
+ // Apply filters from query params
+ const filters = {
+ support_level: req.query.support_level,
+ has_sign: req.query.has_sign === 'true' ? true : req.query.has_sign === 'false' ? false : undefined,
+ sign_size: req.query.sign_size,
+ has_email: req.query.has_email === 'true' ? true : req.query.has_email === 'false' ? false : undefined,
+ has_phone: req.query.has_phone === 'true' ? true : req.query.has_phone === 'false' ? false : undefined
+ };
+
+ // Filter locations within cut boundaries
+ const filteredLocations = spatialUtils.filterLocationsInCut(
+ locationsResponse.list,
+ cut,
+ filters
+ );
+
+ // Calculate statistics
+ const statistics = spatialUtils.calculateCutStatistics(filteredLocations);
+
+ const cutName = cut.name || cut.Name || cut.title || cut.Title || 'Unknown';
+ logger.info(`Found ${filteredLocations.length} locations in cut: ${cutName}`);
+ res.json({
+ locations: filteredLocations,
+ statistics,
+ cut: { id: cut.id || cut.Id || cut.ID, name: cutName }
+ });
+
+ } catch (error) {
+ logger.error('Error getting locations in cut:', error);
+ res.status(500).json({
+ error: 'Failed to get locations in cut',
+ details: error.message
+ });
+ }
+ }
+
+ /**
+ * Export locations within a cut as CSV - admin only
+ */
+ async exportCutLocations(req, res) {
+ try {
+ const { id } = req.params;
+ const { isAdmin } = req.user || {};
+
+ if (!isAdmin) {
+ return res.status(403).json({ error: 'Admin access required' });
+ }
+
+ // Get the cut
+ const cut = await nocodbService.getById(config.CUTS_TABLE_ID, id);
+ if (!cut) {
+ return res.status(404).json({ error: 'Cut not found' });
+ }
+
+ // Check if export is enabled for this cut
+ if (cut.export_enabled === false) {
+ return res.status(403).json({ error: 'Export is disabled for this cut' });
+ }
+
+ // Get all locations
+ const locationsResponse = await nocodbService.getAll(config.LOCATIONS_TABLE_ID);
+ if (!locationsResponse || !locationsResponse.list) {
+ return res.json({ locations: [] });
+ }
+
+ // Apply filters from query params
+ const filters = {
+ support_level: req.query.support_level,
+ has_sign: req.query.has_sign === 'true' ? true : req.query.has_sign === 'false' ? false : undefined,
+ sign_size: req.query.sign_size,
+ has_email: req.query.has_email === 'true' ? true : req.query.has_email === 'false' ? false : undefined,
+ has_phone: req.query.has_phone === 'true' ? true : req.query.has_phone === 'false' ? false : undefined
+ };
+
+ // Filter locations within cut boundaries
+ const filteredLocations = spatialUtils.filterLocationsInCut(
+ locationsResponse.list,
+ cut,
+ filters
+ );
+
+ // Generate CSV content
+ const csvHeaders = [
+ 'ID', 'First Name', 'Last Name', 'Email', 'Phone', 'Address',
+ 'Unit Number', 'Support Level', 'Has Sign', 'Sign Size',
+ 'Latitude', 'Longitude', 'Notes'
+ ];
+
+ const csvRows = filteredLocations.map(location => [
+ location.id || '',
+ location.first_name || '',
+ location.last_name || '',
+ location.email || '',
+ location.phone || '',
+ location.address || '',
+ location.unit_number || '',
+ location.support_level || '',
+ location.sign ? 'Yes' : 'No',
+ location.sign_size || '',
+ location.latitude || '',
+ location.longitude || '',
+ (location.notes || '').replace(/"/g, '""') // Escape quotes in notes
+ ]);
+
+ const csvContent = [
+ csvHeaders.join(','),
+ ...csvRows.map(row => row.map(field => `"${field}"`).join(','))
+ ].join('\n');
+
+ const cutName = (cut.name || 'cut').replace(/[^a-zA-Z0-9]/g, '_');
+ const timestamp = new Date().toISOString().split('T')[0];
+ const filename = `${cutName}_locations_${timestamp}.csv`;
+
+ res.setHeader('Content-Type', 'text/csv');
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
+ res.send(csvContent);
+
+ logger.info(`Exported ${filteredLocations.length} locations from cut: ${cut.name}`);
+
+ } catch (error) {
+ logger.error('Error exporting cut locations:', error);
+ res.status(500).json({
+ error: 'Failed to export cut locations',
+ details: error.message
+ });
+ }
+ }
+
+ /**
+ * Update cut settings (visibility, filters, etc.) - admin only
+ */
+ async updateCutSettings(req, res) {
+ try {
+ const { id } = req.params;
+ const { isAdmin } = req.user || {};
+
+ if (!isAdmin) {
+ return res.status(403).json({ error: 'Admin access required' });
+ }
+
+ const {
+ show_locations,
+ export_enabled,
+ assigned_to,
+ filter_settings,
+ last_canvassed,
+ completion_percentage
+ } = req.body;
+
+ const updateData = {
+ updated_at: new Date().toISOString()
+ };
+
+ // Only include fields that are provided
+ if (show_locations !== undefined) updateData.show_locations = show_locations;
+ if (export_enabled !== undefined) updateData.export_enabled = export_enabled;
+ if (assigned_to !== undefined) updateData.assigned_to = assigned_to;
+ if (filter_settings !== undefined) updateData.filter_settings = JSON.stringify(filter_settings);
+ if (last_canvassed !== undefined) updateData.last_canvassed = last_canvassed;
+ if (completion_percentage !== undefined) updateData.completion_percentage = completion_percentage;
+
+ const response = await nocodbService.update(
+ config.CUTS_TABLE_ID,
+ id,
+ updateData
+ );
+
+ logger.info(`Updated cut settings for cut ID: ${id}`);
+ res.json(response);
+
+ } catch (error) {
+ logger.error('Error updating cut settings:', error);
+ res.status(500).json({
+ error: 'Failed to update cut settings',
+ details: error.message
+ });
+ }
+ }
+
+ /**
+ * Get cut statistics - admin only
+ */
+ async getCutStatistics(req, res) {
+ try {
+ const { id } = req.params;
+ const { isAdmin } = req.user || {};
+
+ if (!isAdmin) {
+ return res.status(403).json({ error: 'Admin access required' });
+ }
+
+ // Get the cut
+ const cut = await nocodbService.getById(config.CUTS_TABLE_ID, id);
+ if (!cut) {
+ return res.status(404).json({ error: 'Cut not found' });
+ }
+
+ // Get all locations
+ const locationsResponse = await nocodbService.getAll(config.LOCATIONS_TABLE_ID);
+ if (!locationsResponse || !locationsResponse.list) {
+ return res.json({
+ statistics: { total_locations: 0 },
+ cut: { id: cut.id, name: cut.name }
+ });
+ }
+
+ // Get locations within cut boundaries (no additional filters)
+ const locationsInCut = spatialUtils.filterLocationsInCut(
+ locationsResponse.list,
+ cut,
+ {} // No additional filters for statistics
+ );
+
+ // Calculate statistics
+ const statistics = spatialUtils.calculateCutStatistics(locationsInCut);
+
+ // Add cut metadata
+ const cutStats = {
+ ...statistics,
+ cut_metadata: {
+ id: cut.id,
+ name: cut.name,
+ category: cut.category,
+ assigned_to: cut.assigned_to,
+ completion_percentage: cut.completion_percentage || 0,
+ last_canvassed: cut.last_canvassed,
+ created_at: cut.created_at
+ }
+ };
+
+ logger.info(`Generated statistics for cut: ${cut.name}`);
+ res.json({ statistics: cutStats });
+
+ } catch (error) {
+ logger.error('Error getting cut statistics:', error);
+ res.status(500).json({
+ error: 'Failed to get cut statistics',
+ details: error.message
+ });
+ }
+ }
}
module.exports = new CutsController();
diff --git a/map/app/public/admin.html b/map/app/public/admin.html
index 2487112..c666e45 100644
--- a/map/app/public/admin.html
+++ b/map/app/public/admin.html
@@ -845,6 +845,134 @@
+
+
+
@@ -1242,6 +1370,10 @@
+
+
+
+
diff --git a/map/app/public/css/modules/cuts.css b/map/app/public/css/modules/cuts.css
index 89c607f..9d122fc 100644
--- a/map/app/public/css/modules/cuts.css
+++ b/map/app/public/css/modules/cuts.css
@@ -994,3 +994,248 @@
color: #666;
margin-top: 2px;
}
+
+/* Cut Location Management Styles */
+.cuts-location-section {
+ margin-top: 20px;
+}
+
+.location-filters {
+ background: #f8f9fa;
+ padding: 15px;
+ border-radius: 8px;
+ margin-bottom: 20px;
+}
+
+.location-filters h4 {
+ margin: 0 0 15px 0;
+ color: #495057;
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.filter-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 15px;
+ align-items: end;
+}
+
+.filter-group {
+ display: flex;
+ flex-direction: column;
+ min-width: 150px;
+}
+
+.filter-group label {
+ font-size: 12px;
+ color: #6c757d;
+ margin-bottom: 5px;
+ font-weight: 500;
+}
+
+.filter-group .form-control {
+ padding: 6px 10px;
+ font-size: 14px;
+ border: 1px solid #dee2e6;
+ border-radius: 4px;
+}
+
+.filter-group button {
+ white-space: nowrap;
+}
+
+/* Cut Statistics Styles */
+.cut-statistics {
+ background: #e9ecef;
+ padding: 15px;
+ border-radius: 8px;
+ margin-bottom: 20px;
+}
+
+.cut-statistics h4 {
+ margin: 0 0 15px 0;
+ color: #495057;
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 10px;
+}
+
+.stat-item {
+ display: flex;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: white;
+ border-radius: 4px;
+ border-left: 4px solid #007bff;
+}
+
+.stat-label {
+ font-size: 14px;
+ color: #6c757d;
+ font-weight: 500;
+}
+
+.stat-value {
+ font-size: 14px;
+ color: #212529;
+ font-weight: 600;
+}
+
+/* Cut Settings Styles */
+.cut-settings {
+ background: #f1f3f4;
+ padding: 15px;
+ border-radius: 8px;
+}
+
+.cut-settings h4 {
+ margin: 0 0 15px 0;
+ color: #495057;
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.setting-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 15px;
+ align-items: center;
+}
+
+.setting-group {
+ display: flex;
+ flex-direction: column;
+ min-width: 150px;
+}
+
+.setting-group label {
+ font-size: 14px;
+ color: #495057;
+ margin-bottom: 5px;
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.setting-group input[type="checkbox"] {
+ margin: 0;
+}
+
+.setting-group input[type="text"],
+.setting-group input[type="number"] {
+ padding: 6px 10px;
+ font-size: 14px;
+ border: 1px solid #dee2e6;
+ border-radius: 4px;
+}
+
+.setting-group span {
+ font-size: 14px;
+ color: #6c757d;
+ margin-left: 5px;
+}
+
+/* Button States for Location Management */
+#toggle-location-visibility.active {
+ background-color: #28a745;
+ border-color: #28a745;
+}
+
+#toggle-location-visibility.inactive {
+ background-color: #6c757d;
+ border-color: #6c757d;
+}
+
+/* Responsive Design */
+@media (max-width: 768px) {
+ .filter-row {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .filter-group {
+ min-width: 100%;
+ }
+
+ .setting-row {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .setting-group {
+ min-width: 100%;
+ }
+
+ .stats-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Enhanced panel styling for location management */
+.cuts-location-section .panel-header {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+}
+
+.cuts-location-section .panel-title {
+ color: white;
+}
+
+.cuts-location-section .btn-sm {
+ font-size: 12px;
+ padding: 4px 8px;
+}
+
+/* Print View Styles */
+@media print {
+ .cuts-location-section {
+ page-break-inside: avoid;
+ }
+
+ .location-filters,
+ .cut-settings {
+ display: none;
+ }
+
+ .cut-statistics {
+ border: 1px solid #000;
+ background: white !important;
+ }
+}
+
+/* Print-specific map styles */
+@media screen {
+ .leaflet-container img {
+ max-width: none !important;
+ opacity: 1 !important;
+ }
+
+ .leaflet-tile-pane {
+ opacity: 1 !important;
+ }
+
+ .leaflet-overlay-pane {
+ opacity: 1 !important;
+ }
+}
+
+/* Ensure tiles are visible during capture */
+.leaflet-tile {
+ opacity: 1 !important;
+ visibility: visible !important;
+}
+
+.leaflet-container .leaflet-tile-pane {
+ z-index: 1 !important;
+}
+
+.leaflet-container .leaflet-overlay-pane {
+ z-index: 2 !important;
+}
diff --git a/map/app/public/js/admin-cuts.js b/map/app/public/js/admin-cuts.js
index 1647d7d..2664cc1 100644
--- a/map/app/public/js/admin-cuts.js
+++ b/map/app/public/js/admin-cuts.js
@@ -429,6 +429,12 @@ class AdminCutsManager {
this.currentCutLayer = null;
this.isInitialized = false; // Add initialization flag
+ // Location management properties
+ this.currentCutLocations = [];
+ this.currentFilters = {};
+ this.locationMarkersLayer = null;
+ this.showingLocations = false;
+
// Bind event handler once to avoid issues with removing listeners
this.boundHandleCutActionClick = this.handleCutActionClick.bind(this);
}
@@ -795,6 +801,9 @@ class AdminCutsManager {
}
});
}
+
+ // Location management event listeners
+ this.setupLocationManagementControls();
}
// Set up toolbar controls for real-time drawing feedback
@@ -1193,6 +1202,9 @@ class AdminCutsManager {
startDrawingBtn.classList.remove('danger');
}
+ // Hide location management panel
+ this.hideLocationManagement();
+
console.log('Cut form and drawing reset complete');
}
@@ -1526,6 +1538,7 @@ class AdminCutsManager {
if (!cut) return;
this.displayCut(cut);
+ this.showLocationManagement(cutId);
const cutName = cut.name || cut.Name || 'Unknown';
this.showStatus(`Viewing cut: ${cutName}`, 'info');
}
@@ -1942,6 +1955,1250 @@ class AdminCutsManager {
console.log(`[${type.toUpperCase()}] ${message}`);
}
}
+
+ // Location Management Methods
+ setupLocationManagementControls() {
+ // Toggle location visibility
+ const toggleLocationBtn = document.getElementById('toggle-location-visibility');
+ if (toggleLocationBtn) {
+ toggleLocationBtn.addEventListener('click', () => this.toggleLocationVisibility());
+ }
+
+ // Export cut locations
+ const exportBtn = document.getElementById('export-cut-locations');
+ if (exportBtn) {
+ exportBtn.addEventListener('click', () => this.exportCutLocations());
+ }
+
+ // Print cut view
+ const printBtn = document.getElementById('print-cut-view');
+ if (printBtn) {
+ printBtn.addEventListener('click', () => this.printCutView());
+ }
+
+ // Apply filters
+ const applyFiltersBtn = document.getElementById('apply-filters');
+ if (applyFiltersBtn) {
+ applyFiltersBtn.addEventListener('click', () => this.applyLocationFilters());
+ }
+
+ // Clear filters
+ const clearFiltersBtn = document.getElementById('clear-filters');
+ if (clearFiltersBtn) {
+ clearFiltersBtn.addEventListener('click', () => this.clearLocationFilters());
+ }
+
+ // Save cut settings
+ const saveSettingsBtn = document.getElementById('save-cut-settings');
+ if (saveSettingsBtn) {
+ saveSettingsBtn.addEventListener('click', () => this.saveCutSettings());
+ }
+ }
+
+ async toggleLocationVisibility() {
+ if (!this.currentCutId) {
+ this.showStatus('No cut selected', 'warning');
+ return;
+ }
+
+ const toggleBtn = document.getElementById('toggle-location-visibility');
+
+ if (this.showingLocations) {
+ // Hide locations
+ if (this.locationMarkersLayer) {
+ this.cutsMap.removeLayer(this.locationMarkersLayer);
+ }
+ this.showingLocations = false;
+ toggleBtn.textContent = 'Show Locations';
+ toggleBtn.classList.remove('active');
+ toggleBtn.classList.add('inactive');
+ } else {
+ // Show locations
+ await this.loadCutLocations();
+ toggleBtn.textContent = 'Hide Locations';
+ toggleBtn.classList.add('active');
+ toggleBtn.classList.remove('inactive');
+ }
+ }
+
+ async loadCutLocations() {
+ if (!this.currentCutId) return;
+
+ try {
+ const filters = this.getCurrentFilters();
+ const queryParams = new URLSearchParams(filters);
+
+ const response = await fetch(`/api/cuts/${this.currentCutId}/locations?${queryParams}`, {
+ credentials: 'include'
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to load locations: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ this.currentCutLocations = data.locations || [];
+
+ // Update statistics
+ this.updateStatistics(data.statistics);
+
+ // Display locations on map
+ this.displayLocationsOnMap(this.currentCutLocations);
+
+ this.showingLocations = true;
+ this.showStatus(`Loaded ${this.currentCutLocations.length} locations`, 'success');
+
+ } catch (error) {
+ console.error('Error loading cut locations:', error);
+ this.showStatus('Failed to load locations', 'error');
+ }
+ }
+
+ displayLocationsOnMap(locations) {
+ // Remove existing markers
+ if (this.locationMarkersLayer) {
+ this.cutsMap.removeLayer(this.locationMarkersLayer);
+ }
+
+ // Create new markers layer
+ this.locationMarkersLayer = L.layerGroup();
+
+ locations.forEach(location => {
+ if (location.latitude && location.longitude) {
+ const marker = L.marker([location.latitude, location.longitude], {
+ icon: this.createLocationIcon(location)
+ });
+
+ const popupContent = this.createLocationPopup(location);
+ marker.bindPopup(popupContent);
+
+ this.locationMarkersLayer.addLayer(marker);
+ }
+ });
+
+ this.locationMarkersLayer.addTo(this.cutsMap);
+ }
+
+ createLocationIcon(location) {
+ // Create different icons based on support level
+ const supportLevel = location.support_level || location['Support Level'] || 'unknown';
+ const colors = {
+ '1': '#28a745', // Green - Strong support
+ '2': '#ffc107', // Yellow - Lean support
+ '3': '#fd7e14', // Orange - Lean opposition
+ '4': '#dc3545', // Red - Strong opposition
+ 'unknown': '#6c757d' // Grey - Unknown
+ };
+
+ const color = colors[supportLevel] || colors['unknown'];
+
+ return L.divIcon({
+ className: 'location-marker',
+ html: `
`,
+ iconSize: [16, 16],
+ iconAnchor: [8, 8]
+ });
+ }
+
+ createLocationPopup(location) {
+ // Handle different possible field names for NocoDB
+ const firstName = location.first_name || location['First Name'] || '';
+ const lastName = location.last_name || location['Last Name'] || '';
+ const name = [firstName, lastName].filter(Boolean).join(' ') || 'Unknown';
+
+ const address = location.address || location.Address || 'No address';
+ const supportLevel = location.support_level || location['Support Level'] || 'Unknown';
+ const hasSign = location.sign || location.Sign ? 'Yes' : 'No';
+ const email = location.email || location.Email || '';
+ const phone = location.phone || location.Phone || '';
+ const notes = location.notes || location.Notes || '';
+
+ return `
+
+ `;
+ }
+
+ getCurrentFilters() {
+ return {
+ support_level: document.getElementById('support-level-filter')?.value || '',
+ has_sign: document.getElementById('sign-status-filter')?.value || '',
+ sign_size: document.getElementById('sign-size-filter')?.value || '',
+ contact_filter: document.getElementById('contact-filter')?.value || ''
+ };
+ }
+
+ async applyLocationFilters() {
+ if (!this.currentCutId) {
+ this.showStatus('No cut selected', 'warning');
+ return;
+ }
+
+ await this.loadCutLocations();
+ }
+
+ clearLocationFilters() {
+ document.getElementById('support-level-filter').value = '';
+ document.getElementById('sign-status-filter').value = '';
+ document.getElementById('sign-size-filter').value = '';
+ document.getElementById('contact-filter').value = '';
+
+ if (this.currentCutId && this.showingLocations) {
+ this.loadCutLocations();
+ }
+ }
+
+ updateStatistics(statistics) {
+ if (!statistics) return;
+
+ document.getElementById('total-locations').textContent = statistics.total_locations || 0;
+ document.getElementById('support-1').textContent = statistics.support_levels?.['1'] || 0;
+ document.getElementById('support-2').textContent = statistics.support_levels?.['2'] || 0;
+ document.getElementById('has-signs').textContent = statistics.lawn_signs?.has_sign || 0;
+ document.getElementById('has-email').textContent = statistics.contact_info?.has_email || 0;
+ document.getElementById('has-phone').textContent = statistics.contact_info?.has_phone || 0;
+
+ // Show statistics panel
+ document.getElementById('cut-statistics').style.display = 'block';
+ }
+
+ async exportCutLocations() {
+ if (!this.currentCutId) {
+ this.showStatus('No cut selected', 'warning');
+ return;
+ }
+
+ try {
+ const filters = this.getCurrentFilters();
+ const queryParams = new URLSearchParams(filters);
+
+ const response = await fetch(`/api/cuts/${this.currentCutId}/locations/export?${queryParams}`, {
+ credentials: 'include'
+ });
+
+ if (!response.ok) {
+ throw new Error(`Export failed: ${response.statusText}`);
+ }
+
+ // Download the CSV file
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.style.display = 'none';
+ a.href = url;
+
+ // Get filename from response header or use default
+ const contentDisposition = response.headers.get('content-disposition');
+ const filename = contentDisposition
+ ? contentDisposition.split('filename=')[1]?.replace(/"/g, '')
+ : `cut_locations_${new Date().toISOString().split('T')[0]}.csv`;
+
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ this.showStatus('Locations exported successfully', 'success');
+
+ } catch (error) {
+ console.error('Error exporting locations:', error);
+ this.showStatus('Failed to export locations', 'error');
+ }
+ }
+
+ async printCutView() {
+ if (!this.currentCutId) {
+ this.showStatus('No cut selected', 'warning');
+ return;
+ }
+
+ try {
+ // Get cut information
+ const cut = this.allCuts.find(c => (c.id || c.Id || c.ID || c._id) == this.currentCutId);
+ const cutName = cut?.name || cut?.Name || 'Unknown Cut';
+
+ // Ensure locations are loaded before printing
+ if (!this.currentCutLocations || this.currentCutLocations.length === 0) {
+ this.showStatus('Loading locations for print...', 'info');
+ try {
+ await this.loadCutLocations();
+ } catch (loadError) {
+ console.log('Failed to load locations for print:', loadError);
+ this.currentCutLocations = [];
+ }
+ }
+
+ console.log('Print: Using', this.currentCutLocations.length, 'locations');
+
+ // Try multiple methods to capture the map
+ let mapImageDataUrl = null;
+ let mapInfo = '';
+
+ try {
+ // Method 1: Try Leaflet's built-in screenshot capability using canvas
+ mapImageDataUrl = await this.captureMapWithLeaflet();
+
+ if (!mapImageDataUrl) {
+ // Method 2: Try using DOM-to-image if available
+ mapImageDataUrl = await this.captureMapWithDomToImage();
+ }
+
+ if (!mapImageDataUrl) {
+ // Method 3: Try browser's built-in canvas capture
+ mapImageDataUrl = await this.captureMapWithCanvas();
+ }
+
+ if (mapImageDataUrl) {
+ // We successfully captured the map
+ mapInfo = `
+
+
Cut Map View
+

+
+ Map showing cut boundaries and ${this.currentCutLocations.length} location markers
+
+
+ `;
+ } else {
+ // Method 4: Generate a static map visualization
+ const staticMap = await this.generateStaticMapImage();
+ if (staticMap) {
+ mapInfo = staticMap;
+ } else {
+ // Fallback to map information
+ throw new Error('All map capture methods failed');
+ }
+ }
+
+ } catch (mapError) {
+ console.log('Map capture failed, using fallback info:', mapError);
+ // Get map bounds and center for basic map info
+ const mapBounds = this.cutsMap.getBounds();
+ const center = this.cutsMap.getCenter();
+ const zoom = this.cutsMap.getZoom();
+
+ mapInfo = `
+
+
Cut Boundaries & Location Summary
+
+
+
Map Center:
${center.lat.toFixed(6)}, ${center.lng.toFixed(6)}
+
Zoom Level: ${zoom}
+
+
+
Geographic Bounds:
+ North: ${mapBounds.getNorth().toFixed(6)}
+ South: ${mapBounds.getSouth().toFixed(6)}
+ East: ${mapBounds.getEast().toFixed(6)}
+ West: ${mapBounds.getWest().toFixed(6)}
+
+
+
+
Cut Statistics:
+ Total Locations: ${this.currentCutLocations.length}
+ Active Filters: ${this.getActiveFiltersCount()}
+ Showing: ${this.currentCutLocations.length} locations
+
+
+
+
+ Individual location coordinates and details are listed in the table below.
+
+
+ `;
+ }
+
+ // Create print content with map info and location data
+ this.generatePrintContent(cutName, cut, mapInfo);
+
+ } catch (error) {
+ console.error('Error creating print view:', error);
+ this.showStatus('Failed to create print view', 'error');
+
+ // Fallback to simple print without map
+ this.printCutViewSimple();
+ }
+ }
+
+ async captureMapWithLeaflet() {
+ try {
+ // Simple approach: try to get the map's SVG or canvas content
+ const mapContainer = this.cutsMap.getContainer();
+
+ // Look for the leaflet pane that contains the actual map content
+ const mapPane = mapContainer.querySelector('.leaflet-map-pane');
+ if (!mapPane) {
+ console.log('Leaflet map pane not found');
+ return null;
+ }
+
+ // Try to find any canvas elements in the map
+ const canvases = mapPane.querySelectorAll('canvas');
+ if (canvases.length > 0) {
+ console.log('Found canvas elements, attempting to capture...');
+ const canvas = canvases[0];
+ try {
+ const dataUrl = canvas.toDataURL('image/png');
+ console.log('Successfully captured canvas');
+ return dataUrl;
+ } catch (canvasError) {
+ console.log('Canvas capture failed:', canvasError);
+ }
+ }
+
+ // Try SVG capture
+ const svgs = mapPane.querySelectorAll('svg');
+ if (svgs.length > 0) {
+ console.log('Found SVG elements, attempting to capture...');
+ try {
+ const svg = svgs[0];
+ const svgData = new XMLSerializer().serializeToString(svg);
+ const svgBlob = new Blob([svgData], {type: 'image/svg+xml;charset=utf-8'});
+ const svgUrl = URL.createObjectURL(svgBlob);
+
+ return new Promise((resolve) => {
+ const img = new Image();
+ img.onload = () => {
+ const canvas = document.createElement('canvas');
+ canvas.width = 800;
+ canvas.height = 600;
+ const ctx = canvas.getContext('2d');
+ ctx.fillStyle = '#f8f9fa';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
+ URL.revokeObjectURL(svgUrl);
+ console.log('Successfully captured SVG');
+ resolve(canvas.toDataURL('image/png'));
+ };
+ img.onerror = () => {
+ URL.revokeObjectURL(svgUrl);
+ console.log('SVG image load failed');
+ resolve(null);
+ };
+ img.src = svgUrl;
+ });
+ } catch (svgError) {
+ console.log('SVG capture failed:', svgError);
+ }
+ }
+
+ console.log('No capturable elements found in Leaflet map');
+ return null;
+
+ } catch (error) {
+ console.log('Leaflet capture failed:', error);
+ return null;
+ }
+ }
+
+ async captureMapWithDomToImage() {
+ try {
+ // Check if dom-to-image is available
+ if (typeof domtoimage === 'undefined') {
+ console.log('dom-to-image library not available');
+ return null;
+ }
+
+ const mapElement = this.cutsMap.getContainer();
+
+ // Add some options to improve capture quality
+ const options = {
+ width: 800,
+ height: 600,
+ quality: 0.95,
+ bgcolor: '#f8f9fa',
+ style: {
+ transform: 'scale(1)',
+ transformOrigin: 'top left',
+ width: '800px',
+ height: '600px'
+ },
+ filter: (node) => {
+ // Skip some problematic elements
+ if (node.classList && node.classList.contains('leaflet-control-container')) {
+ return false;
+ }
+ return true;
+ }
+ };
+
+ console.log('Attempting to capture map with dom-to-image...');
+ const dataUrl = await domtoimage.toPng(mapElement, options);
+ console.log('Map capture successful with dom-to-image');
+ return dataUrl;
+
+ } catch (error) {
+ console.log('Dom-to-image capture failed:', error);
+ return null;
+ }
+ }
+
+ async captureMapWithCanvas() {
+ try {
+ // Try the simple canvas approach for SVG/Canvas elements
+ const mapElement = this.cutsMap.getContainer();
+
+ // Check if the map container has a canvas child
+ const canvasElements = mapElement.querySelectorAll('canvas');
+ if (canvasElements.length > 0) {
+ // Use the first canvas
+ const sourceCanvas = canvasElements[0];
+ return sourceCanvas.toDataURL('image/png');
+ }
+
+ // Try to capture SVG elements
+ const svgElements = mapElement.querySelectorAll('svg');
+ if (svgElements.length > 0) {
+ const svg = svgElements[0];
+ const serializer = new XMLSerializer();
+ const svgData = serializer.serializeToString(svg);
+ const svgBlob = new Blob([svgData], {type: 'image/svg+xml;charset=utf-8'});
+ const url = URL.createObjectURL(svgBlob);
+
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => {
+ const canvas = document.createElement('canvas');
+ canvas.width = svg.clientWidth || 800;
+ canvas.height = svg.clientHeight || 600;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(img, 0, 0);
+ URL.revokeObjectURL(url);
+ resolve(canvas.toDataURL('image/png'));
+ };
+ img.onerror = () => {
+ URL.revokeObjectURL(url);
+ resolve(null);
+ };
+ img.src = url;
+ });
+ }
+
+ return null;
+
+ } catch (error) {
+ console.log('Canvas capture failed:', error);
+ return null;
+ }
+ }
+
+ async generateStaticMapImage() {
+ try {
+ // Create a static map representation using CSS and HTML
+ const mapBounds = this.cutsMap.getBounds();
+ const center = this.cutsMap.getCenter();
+ const zoom = this.cutsMap.getZoom();
+
+ // Get current locations for this cut
+ const cutLocations = this.currentCutLocations || [];
+ console.log('Static map: Using locations:', cutLocations.length);
+ console.log('Static map: Sample location:', cutLocations[0]);
+
+ // Calculate location positions within map bounds
+ const locationMarkers = cutLocations.map(location => {
+ const lat = parseFloat(location.latitude || location.Latitude || 0);
+ const lng = parseFloat(location.longitude || location.Longitude || 0);
+
+ console.log('Processing location:', location.first_name || location['First Name'], 'Coords:', lat, lng);
+
+ if (!lat || !lng) {
+ console.log('Invalid coordinates for location:', location);
+ return null;
+ }
+
+ // Convert to percentage positions within the map bounds
+ const latPercent = ((lat - mapBounds.getSouth()) / (mapBounds.getNorth() - mapBounds.getSouth())) * 100;
+ const lngPercent = ((lng - mapBounds.getWest()) / (mapBounds.getEast() - mapBounds.getWest())) * 100;
+
+ return {
+ ...location,
+ latPercent: Math.max(2, Math.min(98, 100 - latPercent)), // Invert Y for screen coordinates
+ lngPercent: Math.max(2, Math.min(98, lngPercent))
+ };
+ }).filter(Boolean);
+
+ console.log('Static map: Calculated positions for', locationMarkers.length, 'locations');
+
+ // Get the cut geometry for visualization
+ let cutPath = '';
+ let cutPolygonPoints = '';
+
+ if (this.currentCutLayer && this.currentCutLayer.getLatLngs) {
+ try {
+ const latLngs = this.currentCutLayer.getLatLngs();
+ const coordinates = Array.isArray(latLngs[0]) ? latLngs[0] : latLngs;
+
+ // Convert coordinates to percentages for the map visualization
+ const pathPoints = coordinates.map(coord => {
+ const lat = coord.lat;
+ const lng = coord.lng;
+ const latPercent = ((lat - mapBounds.getSouth()) / (mapBounds.getNorth() - mapBounds.getSouth())) * 100;
+ const lngPercent = ((lng - mapBounds.getWest()) / (mapBounds.getEast() - mapBounds.getWest())) * 100;
+ return `${Math.max(0, Math.min(100, lngPercent))}%,${Math.max(0, Math.min(100, 100 - latPercent))}%`;
+ });
+
+ cutPolygonPoints = pathPoints.join(' ');
+
+ if (pathPoints.length > 0) {
+ cutPath = `
+
+ `;
+ }
+ } catch (cutError) {
+ console.log('Could not extract cut geometry:', cutError);
+ }
+ }
+
+ // Create a realistic map background pattern
+ const mapBackground = `
+ background-image:
+ /* Street grid pattern */
+ linear-gradient(0deg, rgba(200,200,200,0.3) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(200,200,200,0.3) 1px, transparent 1px),
+ /* Neighborhood blocks */
+ linear-gradient(45deg, rgba(220,220,220,0.2) 25%, transparent 25%),
+ linear-gradient(-45deg, rgba(220,220,220,0.2) 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, rgba(220,220,220,0.2) 75%),
+ linear-gradient(-45deg, transparent 75%, rgba(220,220,220,0.2) 75%);
+ background-size:
+ 40px 40px,
+ 40px 40px,
+ 80px 80px,
+ 80px 80px,
+ 80px 80px,
+ 80px 80px;
+ background-position:
+ 0 0,
+ 0 0,
+ 0 0,
+ 0 20px,
+ 20px -20px,
+ -20px 0px;
+ background-color: #f0f8ff;
+ `;
+
+ // Create a simple visual map representation
+ const mapVisualization = `
+
+
+
+ ${cutPath}
+
+
+ ${!cutPath ? '
' : ''}
+
+
+ ${locationMarkers.map((location, index) => `
+
+ `).join('')}
+
+
+
+
+
+
+
+
+
+
+
+ Zoom: ${zoom}
+ Center: ${center.lat.toFixed(4)}, ${center.lng.toFixed(4)}
+
+
+
+
+
Support Levels
+
Strong Support
+
Lean Support
+
Oppose
+
Strong Oppose
+
+
+
+
+ Cut Boundary
+
+
+
+
+ `;
+
+ return `
+
+
Cut Map View (${locationMarkers.length} locations)
+ ${mapVisualization}
+
+ Visual representation of cut boundaries and location positions with realistic map styling
+
+
+ `;
+
+ } catch (error) {
+ console.log('Static map generation failed:', error);
+ return null;
+ }
+ }
+
+ getSupportColor(supportLevel) {
+ switch(supportLevel) {
+ case '1': return '#28a745'; // Strong Support - Green
+ case '2': return '#ffc107'; // Lean Support - Yellow
+ case '3': return '#fd7e14'; // Oppose - Orange
+ case '4': return '#dc3545'; // Strong Oppose - Red
+ default: return '#6c757d'; // Unknown - Gray
+ }
+ }
+
+ generatePrintContent(cutName, cut, mapContent) {
+ const printWindow = window.open('', '_blank');
+
+ if (!printWindow) {
+ this.showStatus('Popup blocked - please allow popups for print view', 'warning');
+ return;
+ }
+
+ const printContent = `
+
+
+
+
Cut: ${cutName}
+
+
+
+
Cut: ${cutName}
+
+
+
+ ${this.getCurrentFiltersDisplay()}
+
+
Cut Statistics
+
+
+
${document.getElementById('total-locations')?.textContent || '0'}
+
Total Locations
+
+
+
${document.getElementById('support-1')?.textContent || '0'}
+
Strong Support
+
+
+
${document.getElementById('support-2')?.textContent || '0'}
+
Lean Support
+
+
+
${document.getElementById('has-signs')?.textContent || '0'}
+
Lawn Signs
+
+
+
${document.getElementById('has-email')?.textContent || '0'}
+
Email Contacts
+
+
+
${document.getElementById('has-phone')?.textContent || '0'}
+
Phone Contacts
+
+
+
+
Map Information
+ ${mapContent}
+
+
Location Details
+
+
+
+ | Name |
+ Address |
+ Coordinates |
+ Support Level |
+ Contact Info |
+ Lawn Sign |
+ Notes |
+
+
+
+ ${this.currentCutLocations.map(location => {
+ const firstName = location.first_name || location['First Name'] || '';
+ const lastName = location.last_name || location['Last Name'] || '';
+ const name = [firstName, lastName].filter(Boolean).join(' ') || 'Unknown';
+ const address = location.address || location.Address || '';
+ const lat = location.latitude || location.Latitude || location.lat;
+ const lng = location.longitude || location.Longitude || location.lng;
+ const coordinates = (lat && lng) ? `${parseFloat(lat).toFixed(6)}, ${parseFloat(lng).toFixed(6)}` : 'N/A';
+ const supportLevel = location.support_level || location['Support Level'] || '';
+ const email = location.email || location.Email || '';
+ const phone = location.phone || location.Phone || '';
+ const contact = [email, phone].filter(Boolean).join(', ');
+ const hasSign = location.sign || location.Sign ? 'Yes' : 'No';
+ const notes = location.notes || location.Notes || '';
+
+ return `
+
+ | ${name} |
+ ${address} |
+ ${coordinates} |
+ ${supportLevel} |
+ ${contact} |
+ ${hasSign} |
+ ${notes.substring(0, 100)}${notes.length > 100 ? '...' : ''} |
+
+ `;
+ }).join('')}
+
+
+
+
+
Generated by Campaign Map System - ${new Date().toLocaleDateString()}
+
+
+
+ `;
+
+ try {
+ printWindow.document.write(printContent);
+ printWindow.document.close();
+
+ // Give the content time to render, then print
+ setTimeout(() => {
+ printWindow.print();
+ }, 500);
+
+ this.showStatus('Print view generated successfully', 'success');
+
+ } catch (printError) {
+ console.error('Error writing to print window:', printError);
+ printWindow.close();
+ this.showStatus('Print window error - using fallback', 'warning');
+ this.printCutViewSimple();
+ }
+ }
+
+ printCutViewSimple() {
+ // Simple print view without map image
+ if (!this.currentCutId) {
+ this.showStatus('No cut selected', 'warning');
+ return;
+ }
+
+ const cut = this.allCuts.find(c => (c.id || c.Id || c.ID || c._id) == this.currentCutId);
+ const cutName = cut?.name || cut?.Name || 'Unknown Cut';
+
+ const printWindow = window.open('', '_blank');
+
+ if (!printWindow) {
+ this.showStatus('Popup blocked - please allow popups for print view', 'warning');
+ return;
+ }
+
+ const printContent = `
+
+
+
+
Cut: ${cutName}
+
+
+
+
Cut: ${cutName}
+
+
+
+ ${this.getCurrentFiltersDisplay()}
+
+
Cut Statistics
+
+
+
${document.getElementById('total-locations')?.textContent || '0'}
+
Total Locations
+
+
+
${document.getElementById('support-1')?.textContent || '0'}
+
Strong Support
+
+
+
${document.getElementById('support-2')?.textContent || '0'}
+
Lean Support
+
+
+
${document.getElementById('has-signs')?.textContent || '0'}
+
Lawn Signs
+
+
+
${document.getElementById('has-email')?.textContent || '0'}
+
Email Contacts
+
+
+
${document.getElementById('has-phone')?.textContent || '0'}
+
Phone Contacts
+
+
+
+
Location Details
+
+
+
+ | Name |
+ Address |
+ Coordinates |
+ Support Level |
+ Contact Info |
+ Lawn Sign |
+ Notes |
+
+
+
+ ${this.currentCutLocations.map(location => {
+ const firstName = location.first_name || location['First Name'] || '';
+ const lastName = location.last_name || location['Last Name'] || '';
+ const name = [firstName, lastName].filter(Boolean).join(' ') || 'Unknown';
+ const address = location.address || location.Address || '';
+ const lat = location.latitude || location.Latitude || location.lat;
+ const lng = location.longitude || location.Longitude || location.lng;
+ const coordinates = (lat && lng) ? `${parseFloat(lat).toFixed(6)}, ${parseFloat(lng).toFixed(6)}` : 'N/A';
+ const supportLevel = location.support_level || location['Support Level'] || '';
+ const email = location.email || location.Email || '';
+ const phone = location.phone || location.Phone || '';
+ const contact = [email, phone].filter(Boolean).join(', ');
+ const hasSign = location.sign || location.Sign ? 'Yes' : 'No';
+ const notes = location.notes || location.Notes || '';
+
+ return `
+
+ | ${name} |
+ ${address} |
+ ${coordinates} |
+ ${supportLevel} |
+ ${contact} |
+ ${hasSign} |
+ ${notes.substring(0, 100)}${notes.length > 100 ? '...' : ''} |
+
+ `;
+ }).join('')}
+
+
+
+
+
Generated by Campaign Map System - ${new Date().toLocaleDateString()}
+
+
+
+ `;
+
+ try {
+ if (!printWindow || printWindow.closed) {
+ this.showStatus('Print window unavailable', 'error');
+ return;
+ }
+
+ printWindow.document.write(printContent);
+ printWindow.document.close();
+
+ // Give content time to load then print
+ setTimeout(() => {
+ if (!printWindow.closed) {
+ printWindow.print();
+ }
+ }, 300);
+
+ this.showStatus('Simple print view generated', 'success');
+
+ } catch (printError) {
+ console.error('Error in simple print:', printError);
+ if (printWindow && !printWindow.closed) {
+ printWindow.close();
+ }
+ this.showStatus('Print failed - please try again', 'error');
+ }
+ }
+
+ getCurrentFiltersDisplay() {
+ const activeFilters = [];
+
+ // Check location filters
+ const supportFilter = document.getElementById('support-filter')?.value;
+ if (supportFilter && supportFilter !== 'all') {
+ activeFilters.push(`Support Level: ${supportFilter}`);
+ }
+
+ const signFilter = document.getElementById('sign-filter')?.value;
+ if (signFilter && signFilter !== 'all') {
+ activeFilters.push(`Lawn Signs: ${signFilter === 'true' ? 'Yes' : 'No'}`);
+ }
+
+ const contactFilter = document.getElementById('contact-filter')?.value;
+ if (contactFilter && contactFilter !== 'all') {
+ const contactLabels = {
+ 'email': 'Has Email',
+ 'phone': 'Has Phone',
+ 'both': 'Has Both Email & Phone',
+ 'none': 'No Contact Info'
+ };
+ activeFilters.push(`Contact: ${contactLabels[contactFilter] || contactFilter}`);
+ }
+
+ if (activeFilters.length === 0) {
+ return '
Filters: None (showing all locations)
';
+ }
+
+ return `
Active Filters: ${activeFilters.join(', ')}
`;
+ }
+
+ getActiveFiltersCount() {
+ let count = 0;
+
+ const supportFilter = document.getElementById('support-filter')?.value;
+ if (supportFilter && supportFilter !== 'all') count++;
+
+ const signFilter = document.getElementById('sign-filter')?.value;
+ if (signFilter && signFilter !== 'all') count++;
+
+ const contactFilter = document.getElementById('contact-filter')?.value;
+ if (contactFilter && contactFilter !== 'all') count++;
+
+ return count;
+ }
+
+ async saveCutSettings() {
+ if (!this.currentCutId) {
+ this.showStatus('No cut selected', 'warning');
+ return;
+ }
+
+ try {
+ const settings = {
+ show_locations: document.getElementById('show-locations-toggle').checked,
+ export_enabled: document.getElementById('export-enabled-toggle').checked,
+ assigned_to: document.getElementById('assigned-to').value,
+ completion_percentage: parseInt(document.getElementById('completion-percentage').value) || 0
+ };
+
+ const response = await fetch(`/api/cuts/${this.currentCutId}/settings`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ credentials: 'include',
+ body: JSON.stringify(settings)
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to save settings: ${response.statusText}`);
+ }
+
+ this.showStatus('Cut settings saved successfully', 'success');
+
+ } catch (error) {
+ console.error('Error saving cut settings:', error);
+ this.showStatus('Failed to save cut settings', 'error');
+ }
+ }
+
+ showLocationManagement(cutId) {
+ console.log('showLocationManagement called with cutId:', cutId);
+ this.currentCutId = cutId;
+ const locationSection = document.getElementById('cut-location-management');
+ console.log('Location section element:', locationSection);
+
+ if (locationSection) {
+ console.log('Setting location section display to block');
+ locationSection.style.display = 'block';
+
+ // Load cut data to populate settings - use multiple possible ID field names for NocoDB
+ const cut = this.allCuts.find(c =>
+ (c.id || c.Id || c.ID || c._id) == cutId
+ );
+ console.log('Found cut for location management:', cut);
+ console.log('Available cuts:', this.allCuts.map(c => ({
+ id: c.id || c.Id || c.ID || c._id,
+ name: c.name || c.Name
+ })));
+
+ if (cut) {
+ const toggleElement = document.getElementById('show-locations-toggle');
+ const exportElement = document.getElementById('export-enabled-toggle');
+ const assignedElement = document.getElementById('assigned-to');
+ const completionElement = document.getElementById('completion-percentage');
+
+ console.log('Setting up toggles:', {
+ toggleElement: !!toggleElement,
+ exportElement: !!exportElement,
+ assignedElement: !!assignedElement,
+ completionElement: !!completionElement
+ });
+
+ if (toggleElement) toggleElement.checked = (cut.show_locations || cut.Show_locations || cut['Show Locations']) !== false;
+ if (exportElement) exportElement.checked = (cut.export_enabled || cut.Export_enabled || cut['Export Enabled']) !== false;
+ if (assignedElement) assignedElement.value = cut.assigned_to || cut.Assigned_to || cut['Assigned To'] || '';
+ if (completionElement) completionElement.value = cut.completion_percentage || cut.Completion_percentage || cut['Completion Percentage'] || '';
+ }
+ } else {
+ console.error('Location management section not found!');
+ }
+ }
+
+ hideLocationManagement() {
+ const locationSection = document.getElementById('cut-location-management');
+ if (locationSection) {
+ locationSection.style.display = 'none';
+ }
+
+ // Clear current data
+ this.currentCutId = null;
+ this.currentCutLocations = [];
+
+ // Hide locations on map
+ if (this.locationMarkersLayer) {
+ this.cutsMap.removeLayer(this.locationMarkersLayer);
+ }
+ this.showingLocations = false;
+
+ // Reset button state
+ const toggleBtn = document.getElementById('toggle-location-visibility');
+ if (toggleBtn) {
+ toggleBtn.textContent = 'Show Locations';
+ toggleBtn.classList.remove('active');
+ toggleBtn.classList.remove('inactive');
+ }
+ }
}
// Global instance
diff --git a/map/app/routes/cuts.js b/map/app/routes/cuts.js
index 325a5ec..6962ce1 100644
--- a/map/app/routes/cuts.js
+++ b/map/app/routes/cuts.js
@@ -27,4 +27,10 @@ router.post('/', requireAdmin, cutsController.create);
router.put('/:id', requireAdmin, cutsController.update);
router.delete('/:id', requireAdmin, cutsController.delete);
+// New cut enhancement routes - admin only
+router.get('/:id/locations', requireAdmin, cutsController.getLocationsInCut);
+router.get('/:id/locations/export', requireAdmin, cutsController.exportCutLocations);
+router.get('/:id/statistics', requireAdmin, cutsController.getCutStatistics);
+router.put('/:id/settings', requireAdmin, cutsController.updateCutSettings);
+
module.exports = router;
diff --git a/map/app/server.js b/map/app/server.js
index 6962cdf..e83be1b 100644
--- a/map/app/server.js
+++ b/map/app/server.js
@@ -84,6 +84,9 @@ const buildConnectSrc = () => {
// Add Nominatim for geocoding
sources.push('https://nominatim.openstreetmap.org');
+ // Add OpenStreetMap tile servers for dom-to-image map capture
+ sources.push('https://*.tile.openstreetmap.org');
+
// Add localhost for development
if (!config.isProduction) {
sources.push('http://localhost:*');
@@ -100,7 +103,7 @@ app.use(helmet({
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com", "https://cdn.jsdelivr.net"],
- imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org", "https://tiles.stadiamaps.com", "https://unpkg.com"],
+ imgSrc: ["'self'", "data:", "blob:", "https://*.tile.openstreetmap.org", "https://tiles.stadiamaps.com", "https://unpkg.com"],
connectSrc: buildConnectSrc()
}
}
diff --git a/map/app/utils/spatial.js b/map/app/utils/spatial.js
new file mode 100644
index 0000000..92372c4
--- /dev/null
+++ b/map/app/utils/spatial.js
@@ -0,0 +1,188 @@
+
+/**
+ * Spatial Operations Utility
+ * Provides point-in-polygon calculations and location filtering within cut boundaries
+ */
+
+/**
+ * Check if a point is inside a polygon using ray casting algorithm
+ * @param {number} lat - Point latitude
+ * @param {number} lng - Point longitude
+ * @param {Array} polygon - Array of [lng, lat] coordinates
+ * @returns {boolean} True if point is inside polygon
+ */
+function isPointInPolygon(lat, lng, polygon) {
+ let inside = false;
+
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
+ const xi = polygon[i][0], yi = polygon[i][1];
+ const xj = polygon[j][0], yj = polygon[j][1];
+
+ if (((yi > lat) !== (yj > lat)) &&
+ (lng < (xj - xi) * (lat - yi) / (yj - yi) + xi)) {
+ inside = !inside;
+ }
+ }
+
+ return inside;
+}
+
+/**
+ * Check if a location is within a cut's GeoJSON polygon
+ * @param {Object} location - Location object with latitude/longitude
+ * @param {Object} cutGeoJson - GeoJSON polygon object
+ * @returns {boolean} True if location is within cut
+ */
+function isLocationInCut(location, cutGeoJson) {
+ // Handle different possible field names for coordinates
+ const lat = location.latitude || location.Latitude || location.lat;
+ const lng = location.longitude || location.Longitude || location.lng || location.lon;
+
+ if (!lat || !lng || !cutGeoJson) {
+ return false;
+ }
+
+ try {
+ const geojson = typeof cutGeoJson === 'string' ? JSON.parse(cutGeoJson) : cutGeoJson;
+
+ if (geojson.type === 'Polygon') {
+ return isPointInPolygon(lat, lng, geojson.coordinates[0]);
+ } else if (geojson.type === 'MultiPolygon') {
+ return geojson.coordinates.some(polygon =>
+ isPointInPolygon(lat, lng, polygon[0])
+ );
+ }
+
+ return false;
+ } catch (error) {
+ console.error('Error checking point in polygon:', error);
+ return false;
+ }
+}
+
+/**
+ * Filter locations based on cut boundaries and additional criteria
+ * @param {Array} locations - Array of location objects
+ * @param {Object} cut - Cut object with geojson
+ * @param {Object} filters - Additional filter criteria
+ * @returns {Array} Filtered locations within cut
+ */
+function filterLocationsInCut(locations, cut, filters = {}) {
+ // Try multiple possible field names for the geojson data
+ const geojsonData = cut.geojson || cut.Geojson || cut.GeoJSON || cut['GeoJSON Data'] || cut.geojson_data;
+
+ if (!geojsonData) {
+ return [];
+ }
+
+ // First filter by geographic boundaries
+ let filteredLocations = locations.filter(location =>
+ isLocationInCut(location, geojsonData)
+ );
+
+ // Apply additional filters
+ if (filters.support_level) {
+ filteredLocations = filteredLocations.filter(location => {
+ const supportLevel = location.support_level || location['Support Level'];
+ return supportLevel === filters.support_level;
+ });
+ }
+
+ if (filters.has_sign !== undefined) {
+ filteredLocations = filteredLocations.filter(location => {
+ const hasSign = location.sign || location.Sign;
+ return Boolean(hasSign) === filters.has_sign;
+ });
+ }
+
+ if (filters.sign_size) {
+ filteredLocations = filteredLocations.filter(location => {
+ const signSize = location.sign_size || location['Sign Size'];
+ return signSize === filters.sign_size;
+ });
+ }
+
+ if (filters.has_email !== undefined) {
+ filteredLocations = filteredLocations.filter(location => {
+ const email = location.email || location.Email;
+ return Boolean(email) === filters.has_email;
+ });
+ }
+
+ if (filters.has_phone !== undefined) {
+ filteredLocations = filteredLocations.filter(location => {
+ const phone = location.phone || location.Phone;
+ return Boolean(phone) === filters.has_phone;
+ });
+ }
+
+ return filteredLocations;
+}
+
+/**
+ * Calculate statistics for locations within a cut
+ * @param {Array} locations - Array of location objects within cut
+ * @returns {Object} Statistics object
+ */
+function calculateCutStatistics(locations) {
+ const stats = {
+ total_locations: locations.length,
+ support_levels: { '1': 0, '2': 0, '3': 0, '4': 0, 'unknown': 0 },
+ lawn_signs: { has_sign: 0, no_sign: 0, unknown: 0 },
+ sign_sizes: { Regular: 0, Large: 0, Unsure: 0, unknown: 0 },
+ contact_info: { has_email: 0, has_phone: 0, has_both: 0, has_neither: 0 }
+ };
+
+ locations.forEach(location => {
+ // Support level stats - handle different field names
+ const supportLevel = location.support_level || location['Support Level'] || 'unknown';
+ if (stats.support_levels.hasOwnProperty(supportLevel)) {
+ stats.support_levels[supportLevel]++;
+ } else {
+ stats.support_levels.unknown++;
+ }
+
+ // Lawn sign stats - handle different field names
+ const sign = location.sign || location.Sign;
+ if (sign === true || sign === 1 || sign === 'true') {
+ stats.lawn_signs.has_sign++;
+ } else if (sign === false || sign === 0 || sign === 'false') {
+ stats.lawn_signs.no_sign++;
+ } else {
+ stats.lawn_signs.unknown++;
+ }
+
+ // Sign size stats - handle different field names
+ const signSize = location.sign_size || location['Sign Size'] || 'unknown';
+ if (stats.sign_sizes.hasOwnProperty(signSize)) {
+ stats.sign_sizes[signSize]++;
+ } else {
+ stats.sign_sizes.unknown++;
+ }
+
+ // Contact info stats - handle different field names
+ const email = location.email || location.Email;
+ const phone = location.phone || location.Phone;
+ const hasEmail = Boolean(email);
+ const hasPhone = Boolean(phone);
+
+ if (hasEmail && hasPhone) {
+ stats.contact_info.has_both++;
+ } else if (hasEmail) {
+ stats.contact_info.has_email++;
+ } else if (hasPhone) {
+ stats.contact_info.has_phone++;
+ } else {
+ stats.contact_info.has_neither++;
+ }
+ });
+
+ return stats;
+}
+
+module.exports = {
+ isPointInPolygon,
+ isLocationInCut,
+ filterLocationsInCut,
+ calculateCutStatistics
+};
diff --git a/map/build-nocodb.sh b/map/build-nocodb.sh
index 85440eb..4d9873c 100755
--- a/map/build-nocodb.sh
+++ b/map/build-nocodb.sh
@@ -883,6 +883,45 @@ create_cuts_table() {
"title": "Updated At",
"uidt": "DateTime",
"rqd": false
+ },
+ {
+ "column_name": "show_locations",
+ "title": "Show Locations",
+ "uidt": "Checkbox",
+ "rqd": false,
+ "cdf": true
+ },
+ {
+ "column_name": "export_enabled",
+ "title": "Export Enabled",
+ "uidt": "Checkbox",
+ "rqd": false,
+ "cdf": true
+ },
+ {
+ "column_name": "assigned_to",
+ "title": "Assigned To",
+ "uidt": "SingleLineText",
+ "rqd": false
+ },
+ {
+ "column_name": "filter_settings",
+ "title": "Filter Settings",
+ "uidt": "LongText",
+ "rqd": false
+ },
+ {
+ "column_name": "last_canvassed",
+ "title": "Last Canvassed",
+ "uidt": "DateTime",
+ "rqd": false
+ },
+ {
+ "column_name": "completion_percentage",
+ "title": "Completion Percentage",
+ "uidt": "Number",
+ "rqd": false,
+ "cdf": "0"
}
]
}'
diff --git a/mkdocs/docs/overrides/lander.html b/mkdocs/docs/overrides/lander.html
index 14d2bd2..c0cd06f 100644
--- a/mkdocs/docs/overrides/lander.html
+++ b/mkdocs/docs/overrides/lander.html
@@ -1341,8 +1341,8 @@