649 lines
19 KiB
HTML
649 lines
19 KiB
HTML
{{ template "sync/layout/authenticated.html" . }}
|
|
|
|
{{ define "title" }}Planning{{ end }}
|
|
{{ define "extraheader" }}
|
|
<script
|
|
type="text/javascript"
|
|
src="//unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.js"
|
|
></script>
|
|
<script src="/static/js/map-multipoint.js"></script>
|
|
<script
|
|
defer
|
|
src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
|
|
></script>
|
|
|
|
<script>
|
|
// Return two points defining a bounding box for the given points
|
|
function getBoundingBox(points) {
|
|
// Handle empty or invalid input
|
|
if (!points || points.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Initialize with the first point's coordinates
|
|
let minLat = points[0].latitude;
|
|
let maxLat = points[0].latitude;
|
|
let minLng = points[0].longitude;
|
|
let maxLng = points[0].longitude;
|
|
|
|
// Find the min and max for latitude and longitude
|
|
for (const point of points) {
|
|
if (point.latitude < minLat) minLat = point.latitude;
|
|
if (point.latitude > maxLat) maxLat = point.latitude;
|
|
if (point.longitude < minLng) minLng = point.longitude;
|
|
if (point.longitude > maxLng) maxLng = point.longitude;
|
|
}
|
|
|
|
// Return southwest and northeast corners
|
|
return new maplibregl.LngLatBounds(
|
|
new maplibregl.LngLat(minLng, minLat),
|
|
new maplibregl.LngLat(maxLng, maxLat),
|
|
);
|
|
}
|
|
function shortAddress(a) {
|
|
return a.number + " " + a.street + ", " + a.locality + ", " + a.region;
|
|
}
|
|
function updateMap(signals) {
|
|
const map = document.querySelector("map-multipoint");
|
|
const markers = signals.map((s) => s.location);
|
|
map.SetMarkers(markers);
|
|
bounds = getBoundingBox(markers);
|
|
map.FitBounds(bounds, {
|
|
padding: 50,
|
|
});
|
|
}
|
|
function workbench() {
|
|
return {
|
|
// API Configuration
|
|
apiBase: "/api", // Change this to your API base URL
|
|
|
|
// State
|
|
signals: [],
|
|
planFollowups: [],
|
|
leads: [],
|
|
selectedSignals: [],
|
|
loading: false,
|
|
creating: false,
|
|
error: null,
|
|
|
|
filters: {
|
|
species: "",
|
|
type: "",
|
|
sort: "newest",
|
|
},
|
|
|
|
// Initialize - runs when component loads
|
|
init() {
|
|
this.loadData();
|
|
},
|
|
|
|
// Load all data
|
|
async loadData() {
|
|
this.loading = true;
|
|
this.error = null;
|
|
|
|
try {
|
|
await Promise.all([
|
|
this.loadSignals(),
|
|
//this.loadPlanFollowups(),
|
|
this.loadLeads(),
|
|
]);
|
|
} catch (err) {
|
|
this.error = err.message;
|
|
console.error("Error loading data:", err);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
// Load signals from API
|
|
async loadSignals() {
|
|
try {
|
|
// Build query parameters from filters
|
|
const params = new URLSearchParams();
|
|
if (this.filters.species)
|
|
params.append("species", this.filters.species);
|
|
if (this.filters.type) params.append("type", this.filters.type);
|
|
if (this.filters.sort) params.append("sort", this.filters.sort);
|
|
|
|
const response = await fetch(`${this.apiBase}/signal?${params}`);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
this.signals = data.signals || data; // Handle different response formats
|
|
} catch (err) {
|
|
console.error("Error loading signals:", err);
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
// Load plan followups from API
|
|
async loadPlanFollowups() {
|
|
try {
|
|
const response = await fetch(`${this.apiBase}/plan-followups`);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
this.planFollowups = data.followups || data;
|
|
} catch (err) {
|
|
console.error("Error loading plan followups:", err);
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
// Load leads from API
|
|
async loadLeads() {
|
|
try {
|
|
const response = await fetch(`${this.apiBase}/leads`);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
this.leads = data.leads || data;
|
|
} catch (err) {
|
|
console.error("Error loading leads:", err);
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
// Selection methods
|
|
isSelected(id) {
|
|
return this.selectedSignals.some((s) => s.id === id);
|
|
},
|
|
|
|
clearSelection() {
|
|
this.selectedSignals = [];
|
|
},
|
|
|
|
// Create a new lead from selected signals
|
|
async createLead() {
|
|
if (this.selectedSignals.length === 0) return;
|
|
|
|
this.creating = true;
|
|
|
|
try {
|
|
const response = await fetch(`${this.apiBase}/leads`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
// Add authentication headers if needed
|
|
// 'Authorization': `Bearer ${your_token}`
|
|
},
|
|
body: JSON.stringify({
|
|
signal_ids: this.selectedSignals.map((s) => s.id),
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(
|
|
errorData.message || `HTTP error! status: ${response.status}`,
|
|
);
|
|
}
|
|
|
|
const newLead = await response.json();
|
|
|
|
// Add the new lead to the leads list
|
|
this.leads.unshift(newLead);
|
|
|
|
// Clear selection
|
|
this.clearSelection();
|
|
|
|
// Refresh signals to update their status
|
|
await this.loadSignals();
|
|
|
|
// Show success message
|
|
alert(`Lead created successfully! ID: ${newLead.id}`);
|
|
} catch (err) {
|
|
console.error("Error creating lead:", err);
|
|
alert(`Failed to create lead: ${err.message}`);
|
|
} finally {
|
|
this.creating = false;
|
|
}
|
|
},
|
|
|
|
// Mark signals as addressed
|
|
async markAsAddressed() {
|
|
if (this.selectedSignals.length === 0) return;
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`${this.apiBase}/signal/mark-addressed`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
signal_ids: this.selectedSignals.map((s) => s.id),
|
|
}),
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
// Remove addressed signals from the list
|
|
this.signals = this.signals.filter(
|
|
(signal) => !this.selectedSignals.some((s) => s.id === signal.id),
|
|
);
|
|
|
|
this.clearSelection();
|
|
alert("Signals marked as addressed");
|
|
} catch (err) {
|
|
console.error("Error marking signals as addressed:", err);
|
|
alert(`Failed to mark signals: ${err.message}`);
|
|
}
|
|
},
|
|
toggleSignal(signal) {
|
|
const index = this.selectedSignals.findIndex(
|
|
(s) => s.id === signal.id,
|
|
);
|
|
|
|
if (index > -1) {
|
|
this.selectedSignals.splice(index, 1);
|
|
} else {
|
|
this.selectedSignals.push(signal);
|
|
}
|
|
updateMap(this.selectedSignals);
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
{{ end }}
|
|
{{ define "content" }}
|
|
|
|
<div class="container-fluid py-3" x-data="workbench()">
|
|
<!-- Header -->
|
|
<div class="row mb-3">
|
|
<div class="col">
|
|
<h3 class="mb-1">Daily Planning Workbench</h3>
|
|
<div class="text-muted small">
|
|
Signals and leads enter from the left, are investigated in the center,
|
|
and transformed into structured field assignments using tools on the
|
|
right.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-3">
|
|
<!-- LEFT: Incoming Signals & Leads -->
|
|
<div class="col-xl-3">
|
|
<div class="card shadow-sm h-100">
|
|
<div class="card-header bg-white pane-header">
|
|
Incoming Signals & Leads
|
|
<span
|
|
x-show="loading"
|
|
class="spinner-border spinner-border-sm ms-2"
|
|
role="status"
|
|
></span>
|
|
</div>
|
|
<div class="card-body scroll-pane">
|
|
<!-- Error Display -->
|
|
<template x-if="error">
|
|
<div class="error-message">
|
|
<strong>Error:</strong> <span x-text="error"></span>
|
|
<button
|
|
@click="loadData()"
|
|
class="btn btn-sm btn-outline-danger mt-2 w-100"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- FILTERS -->
|
|
<div class="mb-3">
|
|
<div class="filter-label mb-1">Species</div>
|
|
<select
|
|
class="form-select form-select-sm mb-2"
|
|
x-model="filters.species"
|
|
@change="loadSignals()"
|
|
>
|
|
<option value="">All Species</option>
|
|
<option value="aedes_aegypti">Aedes aegypti</option>
|
|
<option value="aedes_albopictus">Aedes albopictus</option>
|
|
<option value="culex_pipiens">Culex pipiens</option>
|
|
<option value="culex_tarsalis">Culex tarsalis</option>
|
|
</select>
|
|
|
|
<div class="filter-label mb-1">Signal Type</div>
|
|
<select
|
|
class="form-select form-select-sm mb-2"
|
|
x-model="filters.type"
|
|
@change="loadSignals()"
|
|
>
|
|
<option value="">All Types</option>
|
|
<option value="public_report">Public Report</option>
|
|
<option value="trap_spike">Trap Spike</option>
|
|
<option value="surveillance">Surveillance Observation</option>
|
|
<option value="residual_expiring">Residual Expiring</option>
|
|
<option value="plan_followup">Plan Follow-Up</option>
|
|
</select>
|
|
|
|
<div class="filter-label mb-1">Sort By</div>
|
|
<select
|
|
class="form-select form-select-sm"
|
|
x-model="filters.sort"
|
|
@change="loadSignals()"
|
|
>
|
|
<option value="newest">Newest First</option>
|
|
<option value="priority">Highest Priority</option>
|
|
<option value="linked">Most Signals Linked</option>
|
|
<option value="species_signal">Strongest Species Signal</option>
|
|
</select>
|
|
</div>
|
|
|
|
<hr />
|
|
|
|
<!-- Loading State -->
|
|
<template x-if="loading && signals.length === 0">
|
|
<div class="loading-spinner">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">Loading...</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Signals -->
|
|
<div class="mb-3" x-show="!loading || signals.length > 0">
|
|
<div class="fw-semibold mb-2">
|
|
Signals
|
|
<span
|
|
class="badge bg-primary"
|
|
x-show="selectedSignals.length > 0"
|
|
x-text="selectedSignals.length"
|
|
></span>
|
|
</div>
|
|
|
|
<template x-if="signals.length === 0 && !loading">
|
|
<div class="text-muted small fst-italic">No signals found</div>
|
|
</template>
|
|
|
|
<template x-for="signal in signals" :key="signal.id">
|
|
<div
|
|
class="border rounded p-2 mb-2 signal-item"
|
|
:class="{ 'selected': isSelected(signal.id) }"
|
|
@click="toggleSignal(signal)"
|
|
>
|
|
<div class="small fw-semibold" x-text="signal.title"></div>
|
|
<div
|
|
class="signal-address"
|
|
x-text="shortAddress(signal.address)"
|
|
></div>
|
|
<div
|
|
class="text-muted small"
|
|
x-text="signal.description"
|
|
></div>
|
|
<template x-if="signal.badge">
|
|
<span
|
|
class="badge bg-secondary mt-1"
|
|
x-text="signal.badge"
|
|
></span>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<hr />
|
|
|
|
<!-- Mosquito Control Plan Followups -->
|
|
<div class="mb-3" x-show="!loading || planFollowups.length > 0">
|
|
<div class="fw-semibold mb-2">
|
|
Mosquito Control Plan Follow-Ups
|
|
</div>
|
|
|
|
<template x-if="planFollowups.length === 0 && !loading">
|
|
<div class="text-muted small fst-italic">
|
|
No plan follow-ups
|
|
</div>
|
|
</template>
|
|
|
|
<template x-for="followup in planFollowups" :key="followup.id">
|
|
<div
|
|
class="border rounded p-2 mb-2 signal-item"
|
|
:class="{ 'selected': isSelected(followup.id) }"
|
|
@click="toggleSignal(followup)"
|
|
>
|
|
<div class="small fw-semibold" x-text="followup.title"></div>
|
|
<div
|
|
class="text-muted small"
|
|
x-text="followup.description"
|
|
></div>
|
|
<span class="badge bg-secondary">Plan</span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<hr />
|
|
|
|
<!-- Leads -->
|
|
<div x-show="!loading || leads.length > 0">
|
|
<div class="fw-semibold mb-2">Existing Leads</div>
|
|
|
|
<template x-if="leads.length === 0 && !loading">
|
|
<div class="text-muted small fst-italic">No existing leads</div>
|
|
</template>
|
|
|
|
<template x-for="lead in leads" :key="lead.id">
|
|
<div class="border rounded p-2 mb-2 signal-item">
|
|
<div class="small fw-semibold" x-text="lead.title"></div>
|
|
<div class="text-muted small" x-text="lead.description"></div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CENTER: Active Workbench -->
|
|
<div class="col-xl-6">
|
|
<div class="card shadow-sm mb-3">
|
|
<div class="card-header bg-white pane-header">
|
|
Active Investigation Workbench
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="map-container">
|
|
<map-multipoint
|
|
id="map"
|
|
centroid="{{ if .Organization.ServiceAreaCentroidGeojson.IsValue }}
|
|
{{ .Organization.ServiceAreaCentroidGeojson.MustGet|json }}
|
|
{{ end }}"
|
|
organization-id="{{ .Organization.ID }}"
|
|
tegola="{{ .URL.Tegola }}"
|
|
xmin="{{ .Organization.ServiceAreaXmin.GetOr 0 }}"
|
|
ymin="{{ .Organization.ServiceAreaYmin.GetOr 0 }}"
|
|
xmax="{{ .Organization.ServiceAreaXmax.GetOr 0 }}"
|
|
ymax="{{ .Organization.ServiceAreaYmax.GetOr 0 }}"
|
|
></map-multipoint>
|
|
</div>
|
|
|
|
<div class="row g-3">
|
|
<div class="col-md-6">
|
|
<div class="card border">
|
|
<div class="card-body">
|
|
<div class="fw-semibold">Selected Signals</div>
|
|
<div
|
|
class="text-muted small"
|
|
x-text="`${selectedSignals.length} Signal${selectedSignals.length !== 1 ? 's' : ''} Selected`"
|
|
></div>
|
|
|
|
<template x-if="selectedSignals.length === 0">
|
|
<div class="text-muted small mt-2 fst-italic">
|
|
Click signals from the left panel to select them
|
|
</div>
|
|
</template>
|
|
|
|
<ul class="small mt-2" x-show="selectedSignals.length > 0">
|
|
<template
|
|
x-for="signal in selectedSignals"
|
|
:key="signal.id"
|
|
>
|
|
<li>
|
|
<span x-text="signal.title"></span>
|
|
<button
|
|
@click="toggleSignal(signal)"
|
|
class="btn btn-sm btn-link text-danger p-0 ms-1"
|
|
style="font-size: 0.7rem;"
|
|
>
|
|
✕
|
|
</button>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
|
|
<button
|
|
x-show="selectedSignals.length > 0"
|
|
@click="clearSelection()"
|
|
class="btn btn-sm btn-outline-secondary mt-2 w-100"
|
|
>
|
|
Clear Selection
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
<div class="card border">
|
|
<div class="card-body">
|
|
<div class="fw-semibold">Derived Lead Strength</div>
|
|
<div class="text-muted small">Signal Convergence Score</div>
|
|
<div class="mt-2">
|
|
<template x-if="selectedSignals.length === 0">
|
|
<span class="badge bg-secondary">No Selection</span>
|
|
</template>
|
|
<template x-if="selectedSignals.length === 1">
|
|
<span class="badge bg-info">Single Signal</span>
|
|
</template>
|
|
<template x-if="selectedSignals.length === 2">
|
|
<span class="badge bg-warning text-dark"
|
|
>Moderate Confidence</span
|
|
>
|
|
</template>
|
|
<template x-if="selectedSignals.length >= 3">
|
|
<span class="badge bg-danger">High Confidence</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
<div class="card border">
|
|
<div class="card-body">
|
|
<div class="fw-semibold">Related Communications</div>
|
|
<div class="text-muted small">
|
|
Inbound & outbound contact
|
|
</div>
|
|
<ul class="small mt-2">
|
|
<li>Resident follow-up requested</li>
|
|
<li>HOA notification sent</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
<div class="card border">
|
|
<div class="card-body">
|
|
<div class="fw-semibold">Priority Context</div>
|
|
<div class="text-muted small">Risk synthesis</div>
|
|
<template
|
|
x-if="selectedSignals.some(s => s.species === 'aedes_aegypti')"
|
|
>
|
|
<span class="badge bg-warning text-dark"
|
|
>Elevated Aedes Risk</span
|
|
>
|
|
</template>
|
|
<template
|
|
x-if="!selectedSignals.some(s => s.species === 'aedes_aegypti')"
|
|
>
|
|
<span class="badge bg-secondary">Standard Priority</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- RIGHT: Transformation Tools -->
|
|
<div class="col-xl-3">
|
|
<div class="card shadow-sm h-100">
|
|
<div class="card-header bg-white pane-header">
|
|
Transformation Tools
|
|
</div>
|
|
<div class="card-body scroll-pane">
|
|
<div class="mb-3">
|
|
<div class="text-muted small mb-2">Signal → Lead</div>
|
|
<button
|
|
class="btn btn-outline-primary tool-button"
|
|
:disabled="selectedSignals.length === 0 || creating"
|
|
@click="createLead()"
|
|
>
|
|
<span x-show="!creating">Create New Lead from Selection</span>
|
|
<span x-show="creating">
|
|
<span class="spinner-border spinner-border-sm me-1"></span>
|
|
Creating...
|
|
</span>
|
|
</button>
|
|
<button
|
|
class="btn btn-outline-secondary tool-button"
|
|
:disabled="selectedSignals.length === 0"
|
|
>
|
|
Add Signals to Existing Lead
|
|
</button>
|
|
<button
|
|
class="btn btn-outline-secondary tool-button"
|
|
:disabled="selectedSignals.length === 0"
|
|
@click="markAsAddressed()"
|
|
>
|
|
Mark Signal as Addressed
|
|
</button>
|
|
</div>
|
|
|
|
<hr />
|
|
|
|
<div class="mb-3">
|
|
<div class="text-muted small mb-2">Lead → Field Assignment</div>
|
|
<button class="btn btn-outline-success tool-button">
|
|
Create Proposed Assignment
|
|
</button>
|
|
<button class="btn btn-outline-secondary tool-button">
|
|
Add Leads to Existing Assignment
|
|
</button>
|
|
<button class="btn btn-outline-secondary tool-button">
|
|
Split Lead
|
|
</button>
|
|
</div>
|
|
|
|
<hr />
|
|
|
|
<div class="mb-3">
|
|
<div class="text-muted small mb-2">Assignment Controls</div>
|
|
<button class="btn btn-outline-dark tool-button">
|
|
Set Priority
|
|
</button>
|
|
<button class="btn btn-outline-dark tool-button">
|
|
Estimate Effort
|
|
</button>
|
|
<button class="btn btn-outline-dark tool-button">
|
|
Send to Operations
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{{ end }}
|