Semi working map cuts view; need to refactor and fix some stuff however stable enough to commit
This commit is contained in:
parent
59491ccdc6
commit
b3cd1a3331
@ -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();
|
||||
|
||||
@ -846,6 +846,134 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cut Location Management -->
|
||||
<div class="cuts-location-section" id="cut-location-management" style="display: none;">
|
||||
<div class="cuts-management-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Location Management</h3>
|
||||
<div class="panel-actions">
|
||||
<button id="toggle-location-visibility" class="btn btn-primary btn-sm">Show Locations</button>
|
||||
<button id="export-cut-locations" class="btn btn-success btn-sm">Export Data</button>
|
||||
<button id="print-cut-view" class="btn btn-secondary btn-sm">Print View</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<!-- Location Filters -->
|
||||
<div class="location-filters">
|
||||
<h4>Filter Locations</h4>
|
||||
<div class="filter-row">
|
||||
<div class="filter-group">
|
||||
<label for="support-level-filter">Support Level:</label>
|
||||
<select id="support-level-filter" class="form-control">
|
||||
<option value="">All Levels</option>
|
||||
<option value="1">Level 1 (Strong Support)</option>
|
||||
<option value="2">Level 2 (Lean Support)</option>
|
||||
<option value="3">Level 3 (Lean Opposition)</option>
|
||||
<option value="4">Level 4 (Strong Opposition)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label for="sign-status-filter">Lawn Sign:</label>
|
||||
<select id="sign-status-filter" class="form-control">
|
||||
<option value="">All</option>
|
||||
<option value="true">Has Sign</option>
|
||||
<option value="false">No Sign</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label for="sign-size-filter">Sign Size:</label>
|
||||
<select id="sign-size-filter" class="form-control">
|
||||
<option value="">All Sizes</option>
|
||||
<option value="Regular">Regular</option>
|
||||
<option value="Large">Large</option>
|
||||
<option value="Unsure">Unsure</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label for="contact-filter">Contact Info:</label>
|
||||
<select id="contact-filter" class="form-control">
|
||||
<option value="">All</option>
|
||||
<option value="email">Has Email</option>
|
||||
<option value="phone">Has Phone</option>
|
||||
<option value="both">Has Both</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<button id="apply-filters" class="btn btn-primary">Apply Filters</button>
|
||||
<button id="clear-filters" class="btn btn-secondary">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cut Statistics -->
|
||||
<div class="cut-statistics" id="cut-statistics" style="display: none;">
|
||||
<h4>Statistics</h4>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Total Locations:</span>
|
||||
<span class="stat-value" id="total-locations">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Support Level 1:</span>
|
||||
<span class="stat-value" id="support-1">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Support Level 2:</span>
|
||||
<span class="stat-value" id="support-2">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Has Lawn Signs:</span>
|
||||
<span class="stat-value" id="has-signs">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Has Email:</span>
|
||||
<span class="stat-value" id="has-email">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Has Phone:</span>
|
||||
<span class="stat-value" id="has-phone">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cut Settings -->
|
||||
<div class="cut-settings">
|
||||
<h4>Cut Settings</h4>
|
||||
<div class="setting-row">
|
||||
<div class="setting-group">
|
||||
<label>
|
||||
<input type="checkbox" id="show-locations-toggle" checked>
|
||||
Show locations on map
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label>
|
||||
<input type="checkbox" id="export-enabled-toggle" checked>
|
||||
Enable data export
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label for="assigned-to">Assigned to:</label>
|
||||
<input type="text" id="assigned-to" class="form-control" placeholder="Volunteer name/email">
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label for="completion-percentage">Completion:</label>
|
||||
<input type="number" id="completion-percentage" class="form-control" min="0" max="100" placeholder="0">
|
||||
<span>%</span>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<button id="save-cut-settings" class="btn btn-success">Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cuts List -->
|
||||
<div class="cuts-list-section">
|
||||
<div class="cuts-management-panel">
|
||||
@ -1242,6 +1370,10 @@
|
||||
<!-- Chart.js library -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
|
||||
<!-- dom-to-image library for map screenshots -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dom-to-image@2.6.0/dist/dom-to-image.min.js"></script>
|
||||
|
||||
<!-- Dashboard JavaScript -->
|
||||
<script src="js/dashboard.js"></script>
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
188
map/app/utils/spatial.js
Normal file
188
map/app/utils/spatial.js
Normal file
@ -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
|
||||
};
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}'
|
||||
|
||||
@ -1341,8 +1341,8 @@
|
||||
<section class="problem-section">
|
||||
<div class="section-content">
|
||||
<div class="section-header">
|
||||
<h2>Your Canvassers Are Struggling</h2>
|
||||
<p>Traditional campaign tools weren't built for the reality of door-to-door work</p>
|
||||
<h2>Your Supporters Are Struggling</h2>
|
||||
<p>Traditional campaign tools weren't built for the reality of political action</p>
|
||||
</div>
|
||||
<div class="problem-grid">
|
||||
<div class="problem-card">
|
||||
@ -1383,7 +1383,7 @@
|
||||
<section class="solution-section" id="features">
|
||||
<div class="section-content">
|
||||
<div class="section-header">
|
||||
<h2>Documentation That Works</h2>
|
||||
<h2>Political Documentation That Works</h2>
|
||||
<p>Everything your team needs, instantly searchable, always accessible, and easy to communicate</p>
|
||||
</div>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user