Break apart the planning columns
This commit is contained in:
parent
0b8bea393e
commit
b152cf9c36
11 changed files with 821 additions and 585 deletions
10
api/user.go
10
api/user.go
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
201
ts/components/MapProxiedArcgisTile.vue
Normal file
201
ts/components/MapProxiedArcgisTile.vue
Normal 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>
|
||||||
81
ts/components/PlanningColumnAction.vue
Normal file
81
ts/components/PlanningColumnAction.vue
Normal 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>
|
||||||
174
ts/components/PlanningColumnDetail.vue
Normal file
174
ts/components/PlanningColumnDetail.vue
Normal 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>
|
||||||
229
ts/components/PlanningColumnList.vue
Normal file
229
ts/components/PlanningColumnList.vue
Normal 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>
|
||||||
|
|
@ -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
52
ts/store/signal.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -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>
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue