Break apart the planning columns

This commit is contained in:
Eli Ribble 2026-03-22 09:54:02 +00:00
parent 0b8bea393e
commit b152cf9c36
No known key found for this signature in database
11 changed files with 821 additions and 585 deletions

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"net/http" "net/http"
"github.com/Gleipnir-Technology/nidus-sync/config"
"github.com/Gleipnir-Technology/nidus-sync/html" "github.com/Gleipnir-Technology/nidus-sync/html"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http" nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
"github.com/Gleipnir-Technology/nidus-sync/platform" "github.com/Gleipnir-Technology/nidus-sync/platform"
@ -12,10 +13,12 @@ import (
type contentURLAPI struct { type contentURLAPI struct {
Communication string `json:"communication"` Communication string `json:"communication"`
PublicreportMessage string `json:"publicreport_message"` PublicreportMessage string `json:"publicreport_message"`
Signal string `json:"signal"`
} }
type contentURLs struct { type contentURLs struct {
API contentURLAPI `json:"api"` API contentURLAPI `json:"api"`
Tegola string `json:"tegola"` Tegola string `json:"tegola"`
Tile string `json:"tile"`
} }
type contentUserSelf struct { type contentUserSelf struct {
Self platform.User `json:"self"` Self platform.User `json:"self"`
@ -27,6 +30,11 @@ func getUserSelf(ctx context.Context, r *http.Request, user platform.User, query
if err != nil { if err != nil {
return nil, nhttp.NewError("get notifications: %w", err) return nil, nhttp.NewError("get notifications: %w", err)
} }
org, err := platform.OrganizationByID(ctx, int(user.Organization.ID))
if err != nil {
return nil, nhttp.NewError("get org: %w", err)
}
user.Organization = *org
user.NotificationCounts = *counts user.NotificationCounts = *counts
urls := html.NewContentURL() urls := html.NewContentURL()
return &contentUserSelf{ return &contentUserSelf{
@ -35,8 +43,10 @@ func getUserSelf(ctx context.Context, r *http.Request, user platform.User, query
API: contentURLAPI{ API: contentURLAPI{
Communication: urls.API.Communication, Communication: urls.API.Communication,
PublicreportMessage: urls.API.Publicreport.Message, PublicreportMessage: urls.API.Publicreport.Message,
Signal: config.MakeURLNidus("/api/signal"),
}, },
Tegola: urls.Tegola, Tegola: urls.Tegola,
Tile: config.MakeURLNidus("/api/tile/{z}/{y}/{x}"),
}, },
}, nil }, nil
} }

View file

@ -40,6 +40,7 @@ type contentURLAPI struct {
func newContentURLAPI() contentURLAPI { func newContentURLAPI() contentURLAPI {
return contentURLAPI{ return contentURLAPI{
Communication: config.MakeURLNidus("/api/communication"), Communication: config.MakeURLNidus("/api/communication"),
Publicreport: newContentURLAPIPublicreport(),
} }
} }

View file

@ -53,7 +53,9 @@ func (o Organization) IsCatchall() bool {
} }
func (o Organization) MarshalJSON() ([]byte, error) { func (o Organization) MarshalJSON() ([]byte, error) {
to_marshal := map[string]any{} to_marshal := map[string]any{}
to_marshal["id"] = o.ID
to_marshal["name"] = o.Name() to_marshal["name"] = o.Name()
to_marshal["service_area"] = o.ServiceArea
return json.Marshal(to_marshal) return json.Marshal(to_marshal)
} }
func (o Organization) Name() string { func (o Organization) Name() string {

View file

@ -43,6 +43,7 @@ func Router() chi.Router {
r.Method("GET", "/", authenticatedHandler(getRoot)) r.Method("GET", "/", authenticatedHandler(getRoot))
r.Method("GET", "/communication", authenticatedHandler(getRoot)) r.Method("GET", "/communication", authenticatedHandler(getRoot))
r.Method("GET", "/intelligence", authenticatedHandler(getRoot)) r.Method("GET", "/intelligence", authenticatedHandler(getRoot))
r.Method("GET", "/planning", authenticatedHandler(getRoot))
r.Method("GET", "/admin", authenticatedHandler(getAdminDash)) r.Method("GET", "/admin", authenticatedHandler(getAdminDash))
r.Method("GET", "/cell/{cell}", authenticatedHandler(getCellDetails)) r.Method("GET", "/cell/{cell}", authenticatedHandler(getCellDetails))
@ -71,7 +72,6 @@ func Router() chi.Router {
r.Method("GET", "/oauth/refresh", authenticatedHandler(getOAuthRefresh)) r.Method("GET", "/oauth/refresh", authenticatedHandler(getOAuthRefresh))
r.Method("GET", "/operations", authenticatedHandler(getOperationsRoot)) r.Method("GET", "/operations", authenticatedHandler(getOperationsRoot))
r.Method("GET", "/parcel", authenticatedHandler(getParcel)) r.Method("GET", "/parcel", authenticatedHandler(getParcel))
r.Method("GET", "/planning", authenticatedHandler(getPlanningRoot))
r.Method("GET", "/pool", authenticatedHandler(getPoolList)) r.Method("GET", "/pool", authenticatedHandler(getPoolList))
r.Method("GET", "/pool/create", authenticatedHandler(getPoolCreate)) r.Method("GET", "/pool/create", authenticatedHandler(getPoolCreate))
r.Method("GET", "/pool/{id}", authenticatedHandler(getPoolByID)) r.Method("GET", "/pool/{id}", authenticatedHandler(getPoolByID))

View file

@ -0,0 +1,201 @@
<template>
<div ref="mapContainer" class="map-container"></div>
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
const props = defineProps({
latitude: {
type: Number,
required: true,
},
longitude: {
type: Number,
required: true,
},
organizationId: {
type: Number,
default: 0,
},
tegola: {
type: String,
default: "",
},
urlTiles: {
type: String,
default: "",
},
});
const emit = defineEmits(["load", "map-click"]);
const mapContainer = ref(null);
const map = ref(null);
const markers = ref([]);
const preOns = ref([]);
// Watch for latitude/longitude changes
watch(
() => [props.latitude, props.longitude],
([newLat, newLng]) => {
if (map.value) {
map.value.jumpTo({
center: [newLng, newLat],
zoom: 19,
});
}
},
);
const initializeMap = () => {
if (!mapContainer.value) return;
map.value = new maplibregl.Map({
center: [props.longitude, props.latitude],
container: mapContainer.value,
style: "https://tiles.stadiamaps.com/styles/osm_bright.json",
zoom: 19,
});
map.value.on("load", () => {
if (props.organizationId !== 0) {
map.value.addSource("tegola", {
type: "vector",
tiles: [
`${props.tegola}maps/nidus/{z}/{x}/{y}?id=${props.organizationId}&organization_id=${props.organizationId}`,
],
});
map.value.addLayer({
id: "service-area",
source: "tegola",
"source-layer": "service-area-bounds",
type: "line",
paint: {
"line-color": "#f00",
},
});
}
map.value.addSource("flyover", {
type: "raster",
tiles: [props.urlTiles],
});
map.value.addLayer({
id: "flyover-layer",
source: "flyover",
type: "raster",
});
emit("load", { map: getCurrentInstance() });
map.value.on("click", (e) => {
emit("map-click", {
lng: e.lngLat.lng,
lat: e.lngLat.lat,
map: getCurrentInstance(),
point: e.point,
});
});
});
// Apply any pre-registered event listeners
for (const on of preOns.value) {
map.value.on(...on);
}
};
const addLayer = (a) => {
return map.value?.addLayer(a);
};
const addSource = (a, b) => {
return map.value?.addSource(a, b);
};
const jumpTo = (args) => {
return map.value?.jumpTo(args);
};
const on = (...args) => {
if (map.value) {
return map.value.on(...args);
} else {
preOns.value.push(args);
}
};
const once = (a, b) => {
return map.value?.once(a, b);
};
const queryRenderedFeatures = (a) => {
return map.value?.queryRenderedFeatures(a);
};
const fitBounds = (bounds, options) => {
return map.value?.fitBounds(bounds, options);
};
const setLayoutProperty = (layout, property, value) => {
return map.value?.setLayoutProperty(layout, property, value);
};
const setMarkers = (newMarkers) => {
console.log("Setting map markers", newMarkers);
markers.value.forEach((marker) => marker.remove());
markers.value = newMarkers;
for (let m of newMarkers) {
m.addTo(map.value);
}
};
const getCurrentInstance = () => {
// Return an object with the public methods
return {
addLayer,
addSource,
jumpTo,
on,
once,
queryRenderedFeatures,
fitBounds,
setLayoutProperty,
setMarkers,
};
};
// Expose methods to parent components
defineExpose({
addLayer,
addSource,
jumpTo,
on,
once,
queryRenderedFeatures,
fitBounds,
setLayoutProperty,
setMarkers,
map,
});
onMounted(() => {
setTimeout(() => initializeMap(), 0);
});
onBeforeUnmount(() => {
if (map.value) {
map.value.remove();
}
});
</script>
<style scoped>
.map-container {
height: 100%;
width: 100%;
}
</style>

View file

@ -0,0 +1,81 @@
<style scoped>
.scroll-pane {
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.tool-button {
width: 100%;
margin-bottom: 0.5rem;
text-align: left;
}
</style>
<template>
<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 v-if="!creating">Create New Lead from Selection</span>
<span v-else>
<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>
</template>
<script setup lang="ts">
interface Props {
creating: boolean;
selectedSignals: int[];
}
const props = defineProps<Props>();
</script>

View file

@ -0,0 +1,174 @@
<style scoped>
.map-container {
height: 400px;
margin-bottom: 1rem;
}
</style>
<template>
<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">
<MapMultipoint
id="map"
:markers="markers"
:organization-id="user.organization.id"
:tegola="user.urls.tegola"
:xmin="user?.organization.service_area?.xmin ?? 0"
:ymin="user?.organization.service_area?.ymin ?? 0"
:xmax="user?.organization.service_area?.xmax ?? 0"
:ymax="user?.organization.service_area?.ymax ?? 0"
></MapMultipoint>
</div>
<div class="row g-3">
<div class="col-md-12">
<div class="card border">
<div class="card-body">
<div class="fw-semibold">Selected Signals</div>
<div class="text-muted small">
{{ selectedSignals.length }} Signal{{
selectedSignals.length !== 1 ? "s" : ""
}}
Selected
</div>
<div
v-if="selectedSignals.length === 0"
class="text-muted small mt-2 fst-italic"
>
Click signals from the left panel to select them
</div>
<table
class="small mt-2 table"
v-show="selectedSignals.length > 0"
>
<tbody>
<tr v-for="signal in selectedSignals" :key="signal.id">
<td>
<button
@click="toggleSignal(signal)"
class="btn btn-sm btn-link text-danger p-0 ms-1"
style="font-size: 0.7rem"
>
<i class="bi bi-x"></i>
</button>
</td>
<td>
<span v-if="signal.type === 'flyover pool'">Pool</span>
<span v-else-if="signal.type === 'publicreport nuisance'"
>Nuisance</span
>
<span v-else-if="signal.type === 'publicreport water'"
>Water</span
>
</td>
<td>
<TimeRelative :time="signal.created"></TimeRelative>
</td>
<td>{{ shortAddress(signal.address) }}</td>
</tr>
</tbody>
</table>
<button
v-show="selectedSignals.length > 0"
@click="clearSelection()"
class="btn btn-sm btn-outline-secondary mt-2 w-100"
>
Clear Selection
</button>
</div>
</div>
</div>
</div>
<div v-show="showMapTile" class="map-container">
<MapProxiedArcgisTile
ref="mapTile"
class="map"
:organization-id="user.organization.id"
:tegola="user.urls.tegola"
:url-tiles="user.urls.tile"
:latitude="selectedSignalLocation()?.latitude ?? 0.0"
:longitude="selectedSignalLocation()?.longitude ?? 0.0"
@map-click="updateSignalLocation"
>
</MapProxiedArcgisTile>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import MapMultipoint from "./MapMultipoint.vue";
import MapProxiedArcgisTile from "./MapProxiedArcgisTile.vue";
import TimeRelative from "./TimeRelative.vue";
import { useUserStore } from "../store/user";
interface Props {
markers: Marker[];
selectedSignals: int[];
}
const props = defineProps<Props>();
const user = useUserStore();
const configureMapTile = () => {
if (!mapTile.value) return;
mapTile.value.on("load", () => {
mapTile.value.addLayer({
id: "parcel",
minzoom: 14,
paint: {
"line-color": "#0f0",
},
source: "tegola",
"source-layer": "parcel",
type: "line",
});
mapTile.value.addLayer({
id: "signal-point",
paint: {
"circle-color": "#0D6EfD",
"circle-radius": 7,
"circle-stroke-width": 2,
"circle-stroke-color": "#024AB6",
},
source: "tegola",
"source-layer": "signal-point",
type: "circle",
});
});
};
const selectedSignalLocation = () => {
const first_pool = props.selectedSignals.reduce((accumulator, current) => {
if (accumulator == null && current.type === "flyover pool") {
return current;
}
return accumulator;
}, null);
return first_pool?.location;
};
const showMapTile = () => {
return props.selectedSignals.value.reduce(
(accumulator, current) => accumulator || current.type === "flyover pool",
false,
);
};
const updateSignalLocation = (event) => {
const signalId = event.detail.signalId;
console.log("map click", signalId, event.detail);
const map = event.detail.map;
const loc = {
latitude: event.detail.lat,
longitude: event.detail.lng,
};
map.SetMarkers([loc]);
poolLocations.value[signalId] = loc;
};
</script>

View file

@ -0,0 +1,229 @@
<style scoped>
.error-message {
background-color: #f8d7da;
border: 1px solid #f5c2c7;
border-radius: 0.25rem;
padding: 1rem;
margin-bottom: 1rem;
color: #842029;
}
.filter-label {
font-size: 0.875rem;
font-weight: 500;
}
.loading-spinner {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
}
.signal-address {
font-size: 0.875rem;
color: #6c757d;
}
.signal-item {
cursor: pointer;
transition: all 0.2s;
}
.signal-item:hover {
background-color: #f8f9fa;
}
.signal-item.selected {
background-color: #e7f3ff;
border-color: #0d6efd;
}
</style>
<template>
<div class="card shadow-sm h-100">
<div class="card-header bg-white pane-header">
Incoming Signals & Leads
<span
v-show="loading"
class="spinner-border spinner-border-sm ms-2"
role="status"
></span>
</div>
<div class="card-body scroll-pane">
<!-- Error Display -->
<div v-if="error" class="error-message">
<strong>Error:</strong> <span>{{ error }}</span>
<button
@click="loadData()"
class="btn btn-sm btn-outline-danger mt-2 w-100"
>
Retry
</button>
</div>
<!-- FILTERS -->
<div class="mb-3">
<div class="filter-label mb-1">Species</div>
<select
class="form-select form-select-sm mb-2 disabled"
disabled
v-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 disabled"
disabled
v-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 disabled"
disabled
v-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 -->
<div v-if="loading" class="loading-spinner">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div v-else>
<div class="mb-3">
<div class="fw-semibold mb-2">
Signals
<span class="badge bg-primary" v-show="selectedSignals.length > 0">
{{ selectedSignals.length }}
</span>
</div>
<div
v-if="signals != null && signals.length === 0"
class="text-muted small fst-italic"
>
No signals found
</div>
<div
v-for="signal in signals"
v-if="signals != null && signals.length > 0"
:key="signal.id"
class="border rounded p-2 mb-2 signal-item"
:class="{ selected: isSelected(signal.id) }"
@click="toggleSignal(signal)"
>
<div class="small fw-semibold">{{ signal.title }}</div>
<div class="signal-address">
{{ shortAddress(signal.address) }}
</div>
<div class="text-muted small">{{ signal.description }}</div>
<span v-if="signal.badge" class="badge bg-secondary mt-1">
{{ signal.badge }}
</span>
</div>
</div>
</div>
<hr />
<!-- Mosquito Control Plan Followups -->
<div class="mb-3" v-show="!loading || planFollowups.length > 0">
<div class="fw-semibold mb-2">Mosquito Control Plan Follow-Ups</div>
<div
v-if="planFollowups.length === 0 && !loading"
class="text-muted small fst-italic"
>
No plan follow-ups
</div>
<div
v-for="followup in planFollowups"
:key="followup.id"
class="border rounded p-2 mb-2 signal-item"
:class="{ selected: isSelected(followup.id) }"
@click="toggleSignal(followup)"
>
<div class="small fw-semibold">{{ followup.title }}</div>
<div class="text-muted small">{{ followup.description }}</div>
<span class="badge bg-secondary">Plan</span>
</div>
</div>
<hr />
<!-- Leads -->
<div v-show="!loading || leads.length > 0">
<div class="fw-semibold mb-2">Existing Leads</div>
<div
v-if="leads.length === 0 && !loading"
class="text-muted small fst-italic"
>
No existing leads
</div>
<div
v-for="lead in leads"
:key="lead.id"
class="border rounded p-2 mb-2 signal-item"
>
<div class="small fw-semibold">{{ lead.title }}</div>
<div class="text-muted small">{{ lead.description }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
interface Props {
error: string | null;
leads: Lead[] | null;
loading: boolean;
planFollowups: Followup[] | null;
selectedSignals: int[];
signals: Signal[] | null;
}
const props = defineProps<Props>();
const filters = ref({
species: "",
type: "",
sort: "newest",
});
const isSelected = (id) => {
return props.selectedSignals.some((s) => s.id === id);
};
const shortAddress = (a) => {
if (!a) return "";
return `${a.number} ${a.street}, ${a.locality}`;
};
</script>

View file

@ -1,16 +1,18 @@
<template> <template>
<div class="h-100"> <div class="container-fluid py-3">
<div class="container-fluid h-100"> <!-- Header -->
<div class="row h-100"> <div class="row mb-3">
<div class="col-md-3 p-0"> <slot name="header"></slot>
<slot name="left"></slot> </div>
</div> <div class="row g-3">
<div class="col-md-6 p-0"> <div class="col-xl-3">
<slot name="center"></slot> <slot name="left"></slot>
</div> </div>
<div class="col-md-3 p-0"> <div class="col-xl-6">
<slot name="right"></slot> <slot name="center"></slot>
</div> </div>
<div class="col-xl-3">
<slot name="right"></slot>
</div> </div>
</div> </div>
</div> </div>

52
ts/store/signal.ts Normal file
View file

@ -0,0 +1,52 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { Signal } from "../types";
import { SSEManager } from "../SSEManager";
import { useUserStore } from "./user";
export const useSignalStore = defineStore("signal", () => {
// State
const all = ref<Signal[] | null>(null);
const loading = ref(false);
const error = ref(null);
// Subscription
SSEManager.subscribe("*", (e) => {
if (e.resource.startsWith("signal")) {
fetchAll();
}
});
// Actions
async function fetchAll() {
const userStore = useUserStore();
if (userStore.urls == null) {
throw new Error("can't fetch without user URL data");
}
loading.value = true;
error.value = null;
try {
const params = new URLSearchParams();
params.append("sort", "-created");
//if (typeFilter.value) params.append("type", typeFilter.value);
const response = await fetch(`${userStore.urls.api.signal}?${params}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
all.value = data.signals;
} catch (err) {
console.error("Error loading signals:", err);
throw err;
}
}
return {
// State
all,
// Actions
fetchAll,
};
});

View file

@ -1,7 +1,14 @@
<style scoped>
/* Add any component-specific styles here */
.pane-header {
font-weight: 600;
border-bottom: 2px solid #dee2e6;
}
</style>
<template> <template>
<div class="container-fluid py-3"> <ThreeColumn>
<!-- Header --> <template #header>
<div class="row mb-3">
<div class="col"> <div class="col">
<h3 class="mb-1">Daily Planning Workbench</h3> <h3 class="mb-1">Daily Planning Workbench</h3>
<div class="text-muted small"> <div class="text-muted small">
@ -10,401 +17,59 @@
right. right.
</div> </div>
</div> </div>
</div> </template>
<template #left>
<div class="row g-3"> <PlanningColumnList
<!-- LEFT: Incoming Signals & Leads --> :error="error"
<div class="col-xl-3"> :leads="leads"
<div class="card shadow-sm h-100"> :loading="loading"
<div class="card-header bg-white pane-header"> :planFollowups="planFollowups"
Incoming Signals & Leads :selectedSignals="selectedSignals"
<span :signals="signal.all"
v-show="loading" />
class="spinner-border spinner-border-sm ms-2" </template>
role="status" <template #center>
></span> <PlanningColumnDetail
</div> :markers="markers"
<div class="card-body scroll-pane"> :selectedSignals="selectedSignals"
<!-- Error Display --> />
<div v-if="error" class="error-message"> </template>
<strong>Error:</strong> <span>{{ error }}</span> <template #right>
<button <PlanningColumnAction
@click="loadData()" :creating="creating"
class="btn btn-sm btn-outline-danger mt-2 w-100" :selectedSignals="selectedSignals"
> />
Retry </template>
</button> </ThreeColumn>
</div>
<!-- FILTERS -->
<div class="mb-3">
<div class="filter-label mb-1">Species</div>
<select
class="form-select form-select-sm mb-2 disabled"
disabled
v-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 disabled"
disabled
v-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 disabled"
disabled
v-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 -->
<div v-if="loading && signals.length === 0" class="loading-spinner">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<!-- Signals -->
<div class="mb-3" v-show="!loading || signals.length > 0">
<div class="fw-semibold mb-2">
Signals
<span
class="badge bg-primary"
v-show="selectedSignals.length > 0"
>
{{ selectedSignals.length }}
</span>
</div>
<div
v-if="signals.length === 0 && !loading"
class="text-muted small fst-italic"
>
No signals found
</div>
<div
v-for="signal in signals"
:key="signal.id"
class="border rounded p-2 mb-2 signal-item"
:class="{ selected: isSelected(signal.id) }"
@click="toggleSignal(signal)"
>
<div class="small fw-semibold">{{ signal.title }}</div>
<div class="signal-address">
{{ shortAddress(signal.address) }}
</div>
<div class="text-muted small">{{ signal.description }}</div>
<span v-if="signal.badge" class="badge bg-secondary mt-1">
{{ signal.badge }}
</span>
</div>
</div>
<hr />
<!-- Mosquito Control Plan Followups -->
<div class="mb-3" v-show="!loading || planFollowups.length > 0">
<div class="fw-semibold mb-2">
Mosquito Control Plan Follow-Ups
</div>
<div
v-if="planFollowups.length === 0 && !loading"
class="text-muted small fst-italic"
>
No plan follow-ups
</div>
<div
v-for="followup in planFollowups"
:key="followup.id"
class="border rounded p-2 mb-2 signal-item"
:class="{ selected: isSelected(followup.id) }"
@click="toggleSignal(followup)"
>
<div class="small fw-semibold">{{ followup.title }}</div>
<div class="text-muted small">{{ followup.description }}</div>
<span class="badge bg-secondary">Plan</span>
</div>
</div>
<hr />
<!-- Leads -->
<div v-show="!loading || leads.length > 0">
<div class="fw-semibold mb-2">Existing Leads</div>
<div
v-if="leads.length === 0 && !loading"
class="text-muted small fst-italic"
>
No existing leads
</div>
<div
v-for="lead in leads"
:key="lead.id"
class="border rounded p-2 mb-2 signal-item"
>
<div class="small fw-semibold">{{ lead.title }}</div>
<div class="text-muted small">{{ lead.description }}</div>
</div>
</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
ref="mapMultipoint"
id="map"
:organization-id="organizationId"
:tegola="tegolaUrl"
:xmin="serviceArea.xmin"
:ymin="serviceArea.ymin"
:xmax="serviceArea.xmax"
:ymax="serviceArea.ymax"
></map-multipoint>
</div>
<div class="row g-3">
<div class="col-md-12">
<div class="card border">
<div class="card-body">
<div class="fw-semibold">Selected Signals</div>
<div class="text-muted small">
{{ selectedSignals.length }} Signal{{
selectedSignals.length !== 1 ? "s" : ""
}}
Selected
</div>
<div
v-if="selectedSignals.length === 0"
class="text-muted small mt-2 fst-italic"
>
Click signals from the left panel to select them
</div>
<table
class="small mt-2 table"
v-show="selectedSignals.length > 0"
>
<tbody>
<tr v-for="signal in selectedSignals" :key="signal.id">
<td>
<button
@click="toggleSignal(signal)"
class="btn btn-sm btn-link text-danger p-0 ms-1"
style="font-size: 0.7rem"
>
<i class="bi bi-x"></i>
</button>
</td>
<td>
<span v-if="signal.type === 'flyover pool'"
>Pool</span
>
<span
v-else-if="
signal.type === 'publicreport nuisance'
"
>Nuisance</span
>
<span
v-else-if="signal.type === 'publicreport water'"
>Water</span
>
</td>
<td>
<time-relative
:time="signal.created"
></time-relative>
</td>
<td>{{ shortAddress(signal.address) }}</td>
</tr>
</tbody>
</table>
<button
v-show="selectedSignals.length > 0"
@click="clearSelection()"
class="btn btn-sm btn-outline-secondary mt-2 w-100"
>
Clear Selection
</button>
</div>
</div>
</div>
</div>
<div v-show="showMapTile" class="map-container">
<map-proxied-arcgis-tile
ref="mapTile"
class="map"
:organization-id="organizationId"
:tegola="tegolaUrl"
:url-tiles="urlTiles"
:latitude="selectedSignalLocation()?.latitude ?? 0.0"
:longitude="selectedSignalLocation()?.longitude ?? 0.0"
@click="updateSignalLocation"
>
</map-proxied-arcgis-tile>
</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 v-if="!creating">Create New Lead from Selection</span>
<span v-else>
<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>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, nextTick } from "vue"; import { computed, ref, onMounted, nextTick } from "vue";
// Props import MapMultipoint from "../components/MapMultipoint.vue";
const props = defineProps({ import MapProxiedArcgisTile from "../components/MapProxiedArcgisTile.vue";
organizationId: { import PlanningColumnAction from "../components/PlanningColumnAction.vue";
type: String, import PlanningColumnDetail from "../components/PlanningColumnDetail.vue";
required: true, import PlanningColumnList from "../components/PlanningColumnList.vue";
}, import ThreeColumn from "../components/layout/ThreeColumn.vue";
tegolaUrl: { import TimeRelative from "../components/TimeRelative.vue";
type: String, import { useSignalStore } from "../store/signal";
required: true, import { useUserStore } from "../store/user";
},
urlTiles: {
type: String,
required: true,
},
serviceArea: {
type: Object,
required: true,
default: () => ({ xmin: 0, ymin: 0, xmax: 0, ymax: 0 }),
},
});
// Refs // Refs
const mapMultipoint = ref(null);
const mapTile = ref(null); const mapTile = ref(null);
// State // State
const apiBase = ref("/api"); const apiBase = ref("/api");
const creating = ref(false); const creating = ref(false);
const error = ref(null); const error = ref(null);
const planFollowups = ref([]);
const leads = ref([]); const leads = ref([]);
const loading = ref(false); const loading = ref(false);
const planFollowups = ref([]);
const poolLocations = ref({}); const poolLocations = ref({});
const showMapTile = ref(false);
const selectedSignals = ref([]); const selectedSignals = ref([]);
const signals = ref([]); const signal = useSignalStore();
const user = useUserStore();
const filters = ref({
species: "",
type: "",
sort: "newest",
});
// Helper functions (outside component) // Helper functions (outside component)
const getBoundingBox = (points) => { const getBoundingBox = (points) => {
@ -429,15 +94,10 @@ const getBoundingBox = (points) => {
new window.maplibregl.LngLat(maxLng, maxLat), new window.maplibregl.LngLat(maxLng, maxLat),
); );
}; };
const markers = computed(() => {
const shortAddress = (a) => { return [];
if (!a) return ""; });
return `${a.number} ${a.street}, ${a.locality}`;
};
const updateMap = (signals) => { const updateMap = (signals) => {
if (!mapMultipoint.value) return;
const locations = signals.map((s) => s.location); const locations = signals.map((s) => s.location);
const markers = locations.map((l) => const markers = locations.map((l) =>
new window.maplibregl.Marker({ new window.maplibregl.Marker({
@ -446,43 +106,14 @@ const updateMap = (signals) => {
}).setLngLat([l.longitude, l.latitude]), }).setLngLat([l.longitude, l.latitude]),
); );
mapMultipoint.value.SetMarkers(markers); /*
const bounds = getBoundingBox(locations); const bounds = getBoundingBox(locations);
if (bounds != null) { if (bounds != null) {
mapMultipoint.value.FitBounds(bounds, { mapMultipoint.value.FitBounds(bounds, {
padding: 50, padding: 50,
}); });
} }
}; */
const configureMapTile = () => {
if (!mapTile.value) return;
mapTile.value.on("load", () => {
mapTile.value.addLayer({
id: "parcel",
minzoom: 14,
paint: {
"line-color": "#0f0",
},
source: "tegola",
"source-layer": "parcel",
type: "line",
});
mapTile.value.addLayer({
id: "signal-point",
paint: {
"circle-color": "#0D6EfD",
"circle-radius": 7,
"circle-stroke-width": 2,
"circle-stroke-color": "#024AB6",
},
source: "tegola",
"source-layer": "signal-point",
type: "circle",
});
});
}; };
// Methods // Methods
@ -491,7 +122,7 @@ const loadData = async () => {
error.value = null; error.value = null;
try { try {
await Promise.all([loadSignals(), loadLeads()]); await signal.fetchAll();
} catch (err) { } catch (err) {
error.value = err.message; error.value = err.message;
console.error("Error loading data:", err); console.error("Error loading data:", err);
@ -500,27 +131,6 @@ const loadData = async () => {
} }
}; };
const loadSignals = async () => {
try {
const params = new URLSearchParams();
if (filters.value.species) params.append("species", filters.value.species);
if (filters.value.type) params.append("type", filters.value.type);
if (filters.value.sort) params.append("sort", filters.value.sort);
const response = await fetch(`${apiBase.value}/signal?${params}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
signals.value = data.signals || data;
} catch (err) {
console.error("Error loading signals:", err);
throw err;
}
};
const loadPlanFollowups = async () => { const loadPlanFollowups = async () => {
try { try {
const response = await fetch(`${apiBase.value}/plan-followups`); const response = await fetch(`${apiBase.value}/plan-followups`);
@ -537,26 +147,6 @@ const loadPlanFollowups = async () => {
} }
}; };
const loadLeads = async () => {
try {
const response = await fetch(`${apiBase.value}/leads`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
leads.value = data.leads || data;
} catch (err) {
console.error("Error loading leads:", err);
throw err;
}
};
const isSelected = (id) => {
return selectedSignals.value.some((s) => s.id === id);
};
const clearSelection = () => { const clearSelection = () => {
selectedSignals.value = []; selectedSignals.value = [];
}; };
@ -588,7 +178,7 @@ const createLead = async () => {
const newLead = await response.json(); const newLead = await response.json();
leads.value.unshift(newLead); leads.value.unshift(newLead);
clearSelection(); clearSelection();
await loadSignals(); await loadData();
} catch (err) { } catch (err) {
console.error("Error creating lead:", err); console.error("Error creating lead:", err);
alert(`Failed to create lead: ${err.message}`); alert(`Failed to create lead: ${err.message}`);
@ -627,16 +217,6 @@ const markAsAddressed = async () => {
} }
}; };
const selectedSignalLocation = () => {
const first_pool = selectedSignals.value.reduce((accumulator, current) => {
if (accumulator == null && current.type === "flyover pool") {
return current;
}
return accumulator;
}, null);
return first_pool?.location;
};
const toggleSignal = (signal) => { const toggleSignal = (signal) => {
const index = selectedSignals.value.findIndex((s) => s.id === signal.id); const index = selectedSignals.value.findIndex((s) => s.id === signal.id);
@ -648,48 +228,15 @@ const toggleSignal = (signal) => {
updateMap(selectedSignals.value); updateMap(selectedSignals.value);
showMapTile.value = selectedSignals.value.reduce(
(accumulator, current) => accumulator || current.type === "flyover pool",
false,
);
console.log("show tile", showMapTile.value); console.log("show tile", showMapTile.value);
if (showMapTile.value) {
nextTick(() => {
configureMapTile();
});
}
};
const updateSignalLocation = (event) => {
const signalId = event.detail.signalId;
console.log("map click", signalId, event.detail);
const map = event.detail.map;
const loc = {
latitude: event.detail.lat,
longitude: event.detail.lng,
};
map.SetMarkers([loc]);
poolLocations.value[signalId] = loc;
}; };
// Lifecycle // Lifecycle
onMounted(() => { onMounted(() => {
loadData(); loadData();
// Subscribe to SSE events
if (window.SSEManager) {
window.SSEManager.subscribe("*", (e) => {
if (e.resource === "sync:signal") {
loadData();
}
});
}
// Configure map multipoint // Configure map multipoint
/*
const map = mapMultipoint.value; const map = mapMultipoint.value;
if (map) { if (map) {
map.on("load", () => { map.on("load", () => {
@ -718,69 +265,6 @@ onMounted(() => {
console.log("Added parcel and signal layers"); console.log("Added parcel and signal layers");
}); });
} }
*/
}); });
</script> </script>
<style scoped>
/* Add any component-specific styles here */
.map-container {
height: 400px;
margin-bottom: 1rem;
}
.scroll-pane {
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.signal-item {
cursor: pointer;
transition: all 0.2s;
}
.signal-item:hover {
background-color: #f8f9fa;
}
.signal-item.selected {
background-color: #e7f3ff;
border-color: #0d6efd;
}
.signal-address {
font-size: 0.875rem;
color: #6c757d;
}
.tool-button {
width: 100%;
margin-bottom: 0.5rem;
text-align: left;
}
.loading-spinner {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
}
.error-message {
background-color: #f8d7da;
border: 1px solid #f5c2c7;
border-radius: 0.25rem;
padding: 1rem;
margin-bottom: 1rem;
color: #842029;
}
.filter-label {
font-size: 0.875rem;
font-weight: 500;
}
.pane-header {
font-weight: 600;
border-bottom: 2px solid #dee2e6;
}
</style>