Start adding other views and our initial stores

This commit is contained in:
Eli Ribble 2026-03-22 00:55:48 +00:00
parent c75c5446f7
commit 736c71eefc
No known key found for this signature in database
9 changed files with 332 additions and 458 deletions

View file

@ -5,424 +5,4 @@
<script src="/static/js/user-selector.js"></script>
{{ end }}
{{ define "content" }}
<div class="container mt-4">
<h2 id="comms-testing">
<i class="bi bi-broadcast-pin"></i> Communications Testing
</h2>
<div class="row">
<div class="col-lg-6">
<!-- SMS Testing -->
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<i class="bi bi-chat-dots"></i> SMS Testing
</div>
<div class="card-body">
<form action="/sudo/sms" method="POST">
<div class="mb-3">
<label for="smsPhone" class="form-label"
>Recipient Phone Number</label
>
<input
type="tel"
class="form-control"
id="smsPhone"
name="smsPhone"
placeholder="+1 (555) 123-4567"
/>
</div>
<div class="mb-3">
<label for="smsMessage" class="form-label">Message</label>
<textarea
class="form-control"
id="smsMessage"
name="smsMessage"
rows="3"
maxlength="130"
placeholder="Enter your SMS message here"
></textarea>
</div>
<button type="submit" class="btn btn-primary">
Send SMS <i class="bi bi-send"></i>
</button>
</form>
</div>
</div>
<!-- MMS Testing -->
<div class="card mb-4">
<div class="card-header bg-success text-white">
<i class="bi bi-image"></i> MMS Testing
</div>
<div class="card-body">
<form>
<div class="mb-3">
<label for="mmsPhone" class="form-label"
>Recipient Phone Number</label
>
<input
type="tel"
class="form-control"
id="mmsPhone"
placeholder="+1 (555) 123-4567"
/>
</div>
<div class="mb-3">
<label for="mmsMessage" class="form-label">Message</label>
<textarea
class="form-control"
id="mmsMessage"
rows="2"
placeholder="Enter your MMS message here"
></textarea>
</div>
<div class="mb-3">
<label for="mmsImage" class="form-label">Attach Image</label>
<input
class="form-control"
type="file"
id="mmsImage"
accept="image/*"
/>
</div>
<button type="submit" class="btn btn-success">
Send MMS <i class="bi bi-send"></i>
</button>
</form>
</div>
</div>
</div>
<div class="col-lg-6">
<!-- RCS Testing -->
<div class="card mb-4">
<div class="card-header bg-info text-white">
<i class="bi bi-chat-square-text"></i> RCS Testing
</div>
<div class="card-body">
<form>
<div class="mb-3">
<label for="rcsPhone" class="form-label"
>Recipient Phone Number</label
>
<input
type="tel"
class="form-control"
id="rcsPhone"
placeholder="+1 (555) 123-4567"
/>
</div>
<div class="mb-3">
<label for="rcsMessage" class="form-label">Message</label>
<textarea
class="form-control"
id="rcsMessage"
rows="3"
placeholder="Enter your RCS message here"
></textarea>
</div>
<div class="mb-3">
<label class="form-label">Interactive Options</label>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="rcsIncludeButtons"
/>
<label class="form-check-label" for="rcsIncludeButtons"
>Include Action Buttons</label
>
</div>
</div>
<button type="submit" class="btn btn-info">
Send RCS <i class="bi bi-send"></i>
</button>
</form>
</div>
</div>
<!-- Email Testing -->
<div class="card mb-4">
<div class="card-header bg-warning text-dark">
<i class="bi bi-envelope"></i> Email Testing
</div>
<div class="card-body">
<form action="/sudo/email" method="POST">
<div class="mb-3">
<label for="emailFrom" class="form-label">From Account</label>
<select class="form-select" id="emailFrom" name="emailFrom">
<option value="{{ .C.ForwardEmailRMOAddress }}">
{{ .C.ForwardEmailRMOAddress }}
</option>
<option value="{{ .C.ForwardEmailNidusAddress }}">
{{ .C.ForwardEmailNidusAddress }}
</option>
</select>
</div>
<div class="mb-3">
<label for="emailTo" class="form-label">Recipient Email</label>
<input
type="email"
class="form-control"
id="emailTo"
name="emailTo"
placeholder="user@example.com"
/>
</div>
<div class="mb-3">
<label for="emailSubject" class="form-label">Subject</label>
<input
type="text"
class="form-control"
id="emailSubject"
name="emailSubject"
placeholder="Email Subject"
/>
</div>
<div class="mb-3">
<label for="emailBody" class="form-label">Message</label>
<textarea
class="form-control"
id="emailBody"
name="emailBody"
placeholder="Enter your email message here"
rows="3"
></textarea>
</div>
<button type="submit" class="btn btn-warning text-dark">
Send Email <i class="bi bi-send"></i>
</button>
</form>
</div>
</div>
</div>
</div>
<!-- SSE Testing -->
<div class="card mb-5">
<div class="card-header bg-danger text-white">
<i class="bi bi-bell"></i> Server-sent event testing
</div>
<div class="card-body">
<form action="/sudo/sse" method="POST">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="organizationID" class="form-label"
>Organization ID</label
>
<input
class="form-control"
id="organization-id"
name="organizationID"
placeholder="Organization ID"
type="text"
value="{{ .Organization.ID }}"
/>
</div>
<div class="mb-3">
<label for="type" class="form-label">Type</label>
<select class="form-select" id="type" name="type">
<option value="created">Created</option>
<option value="deleted">Deleted</option>
<option value="heartbeat">Heartbeat</option>
<option value="sudo">Sudo</option>
<option value="updated">Updated</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="resource" class="form-label">Resource</label>
<input
class="form-control"
id="resource"
name="resource"
type="text"
value="rmo:nuisance"
/>
</div>
<div class="mb-3">
<label for="uriPath" class="form-label">URI path</label>
<input
class="form-control"
id="uri-path"
name="uriPath"
type="text"
value="/report/abcd-1234"
/>
</div>
</div>
</div>
<button type="submit" class="btn btn-danger">
Send SSE<i class="bi bi-send"></i>
</button>
</form>
</div>
</div>
<hr class="my-5" />
<!-- Push Notification Testing -->
<div class="card mb-5">
<div class="card-header bg-danger text-white">
<i class="bi bi-bell"></i> Push Notification Testing
</div>
<div class="card-body">
<form>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="pushUser" class="form-label"
>User ID or Device Token</label
>
<input
type="text"
class="form-control"
id="pushUser"
placeholder="User ID or Device Token"
/>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="pushTitle" class="form-label"
>Notification Title</label
>
<input
type="text"
class="form-control"
id="pushTitle"
placeholder="Notification Title"
/>
</div>
</div>
</div>
<div class="mb-3">
<label for="pushBody" class="form-label">Notification Body</label>
<textarea
class="form-control"
id="pushBody"
rows="2"
placeholder="Enter your notification message here"
></textarea>
</div>
<div class="mb-3">
<label for="pushData" class="form-label"
>Additional Data (JSON)</label
>
<textarea
class="form-control"
id="pushData"
rows="2"
placeholder='{"key": "value"}'
></textarea>
</div>
<button type="submit" class="btn btn-danger">
Send Push Notification <i class="bi bi-send"></i>
</button>
</form>
</div>
</div>
<hr class="my-5" />
<!-- User Impersonation Section -->
<h2 id="user-impersonation">
<i class="bi bi-person-badge"></i> User Impersonation
</h2>
<div class="card">
<div class="card-header bg-dark text-white">
<i class="bi bi-people"></i> Impersonate User
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<label for="userSearch" class="form-label">Search Users</label>
<user-selector></user-selector>
</div>
<div class="col-md-6">
<label for="userRole" class="form-label">Filter by Role</label>
<select class="form-select" id="userRole">
<option value="">All Roles</option>
<option value="admin">Admin</option>
<option value="user">Standard User</option>
<option value="support">Support</option>
<option value="premium">Premium User</option>
</select>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>User ID</th>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td>1001</td>
<td>John Doe</td>
<td>john.doe@example.com</td>
<td><span class="badge bg-primary">Admin</span></td>
<td>
<button class="btn btn-sm btn-primary">
Impersonate <i class="bi bi-box-arrow-in-right"></i>
</button>
</td>
</tr>
<tr>
<td>1002</td>
<td>Jane Smith</td>
<td>jane.smith@example.com</td>
<td><span class="badge bg-info">Support</span></td>
<td>
<button class="btn btn-sm btn-primary">
Impersonate <i class="bi bi-box-arrow-in-right"></i>
</button>
</td>
</tr>
<tr>
<td>1003</td>
<td>Robert Johnson</td>
<td>robert@example.com</td>
<td><span class="badge bg-success">Premium User</span></td>
<td>
<button class="btn btn-sm btn-primary">
Impersonate <i class="bi bi-box-arrow-in-right"></i>
</button>
</td>
</tr>
<tr>
<td>1004</td>
<td>Maria Garcia</td>
<td>maria@example.com</td>
<td><span class="badge bg-secondary">Standard User</span></td>
<td>
<button class="btn btn-sm btn-primary">
Impersonate <i class="bi bi-box-arrow-in-right"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div
class="alert alert-warning mt-3 mb-0"
id="impersonationStatus"
style="display: none;"
>
<i class="bi bi-exclamation-triangle"></i> You are currently
impersonating <strong>John Doe</strong>
<button class="btn btn-sm btn-warning float-end">
Exit Impersonation <i class="bi bi-box-arrow-left"></i>
</button>
</div>
</div>
</div>
</div>
{{ end }}

View file

@ -41,11 +41,11 @@ func Router() chi.Router {
r.Route("/api", api.AddRoutes)
r.Method("GET", "/", authenticatedHandler(getRoot))
r.Method("GET", "/communication", authenticatedHandler(getRoot))
r.Method("GET", "/intelligence", authenticatedHandler(getRoot))
r.Method("GET", "/admin", authenticatedHandler(getAdminDash))
r.Method("GET", "/cell/{cell}", authenticatedHandler(getCellDetails))
r.Method("GET", "/communication", authenticatedHandler(getCommunicationRoot))
r.Method("GET", "/configuration", authenticatedHandler(getConfigurationRoot))
r.Method("GET", "/configuration/integration", authenticatedHandler(getConfigurationIntegration))
r.Method("GET", "/configuration/integration/arcgis", authenticatedHandler(getConfigurationIntegrationArcgis))

View file

@ -2,15 +2,25 @@
<div class="app-container">
<Sidebar />
<MainContent>
<router-view />
<div v-if="userStore.loading">Loading...</div>
<div v-else-if="userStore.error">Error: {{ userStore.error }}</div>
<router-view v-else />
</MainContent>
</div>
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { useUserStore } from "@/store/user";
import Sidebar from "./components/layout/Sidebar.vue";
import MainContent from "./components/layout/MainContent.vue";
import NavigationLink from "./components/layout/common/NavigationLink.vue";
const userStore = useUserStore();
onMounted(() => {
userStore.fetchUser();
});
</script>
<style scoped>

View file

@ -0,0 +1,180 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
interface Props {
centroid: [number, number];
organizationId: number;
tegola: string;
xmin: number;
ymin: number;
xmax: number;
ymax: number;
}
interface CellClickDetail {
cell: string;
}
const props = withDefaults(defineProps<Props>(), {
organizationId: 0,
});
const emit = defineEmits<{
"cell-click": [detail: CellClickDetail];
}>();
const mapContainer = ref<HTMLElement | null>(null);
const map = ref<maplibregl.Map | null>(null);
const initializeMap = () => {
if (!mapContainer.value) return;
const bounds: [[number, number], [number, number]] = [
[props.xmin, props.ymin],
[props.xmax, props.ymax],
];
map.value = new maplibregl.Map({
bounds: bounds,
container: mapContainer.value,
style: "https://tiles.stadiamaps.com/styles/alidade_smooth.json",
});
console.log("Initializing map to bounds", bounds);
map.value.on("load", () => {
if (!map.value) return;
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: "mosquito_source",
type: "fill",
filter: ["==", ["zoom"], ["+", 2, ["to-number", ["get", "resolution"]]]],
source: "tegola",
"source-layer": "mosquito_source",
paint: {
"fill-opacity": 0.4,
"fill-color": "#dc3545",
},
});
map.value.addLayer({
id: "service_request",
type: "fill",
filter: ["==", ["zoom"], ["+", 2, ["to-number", ["get", "resolution"]]]],
source: "tegola",
"source-layer": "service_request",
paint: {
"fill-opacity": 0.4,
"fill-color": "#ffc107",
},
});
map.value.addLayer({
id: "trap",
type: "fill",
filter: ["==", ["zoom"], ["+", 2, ["to-number", ["get", "resolution"]]]],
source: "tegola",
"source-layer": "trap",
paint: {
"fill-opacity": 0.4,
"fill-color": "#0dcaf0",
},
});
map.value.addLayer({
id: "service-area",
source: "tegola",
"source-layer": "service-area-bounds",
type: "line",
paint: {
"line-color": "#f00",
},
});
map.value.on("mouseenter", "mosquito_source", () => {
if (map.value) {
map.value.getCanvas().style.cursor = "pointer";
}
});
map.value.on("mouseleave", "mosquito_source", () => {
if (map.value) {
map.value.getCanvas().style.cursor = "";
}
});
const handleClick = (e: maplibregl.MapLayerMouseEvent) => {
if (!e.features || e.features.length === 0) return;
const feature = e.features[0];
const properties = feature.properties;
emit("cell-click", {
cell: properties.cell,
});
};
map.value.on("click", "mosquito_source", handleClick);
map.value.on("click", "service_request", handleClick);
map.value.on("click", "trap", handleClick);
});
};
const jumpTo = (args: maplibregl.JumpToOptions) => {
if (map.value) {
map.value.jumpTo(args);
}
};
onMounted(() => {
setTimeout(() => initializeMap(), 0);
});
onUnmounted(() => {
if (map.value) {
map.value.remove();
map.value = null;
}
});
// Expose public methods
defineExpose({
jumpTo,
});
</script>
<template>
<div class="map-container">
<div ref="mapContainer" class="map"></div>
</div>
</template>
<style scoped>
.map-container {
background-color: #e9ecef;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
height: 500px;
margin-top: 20px;
position: relative;
}
.map {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
height: 100%;
width: 100%;
}
</style>

View file

@ -2,7 +2,13 @@ import { createRouter, createWebHistory } from "vue-router";
import type { RouteRecordRaw } from "vue-router";
import Home from "./view/Home.vue";
import About from "./view/About.vue";
import Communication from "./view/Communication.vue";
import Configuration from "./view/Configuration.vue";
import Intelligence from "./view/Intelligence.vue";
import Operations from "./view/Operations.vue";
import Planning from "./view/Planning.vue";
import Review from "./view/Review.vue";
import Sudo from "./view/Sudo.vue";
const routes: RouteRecordRaw[] = [
{
@ -10,11 +16,41 @@ const routes: RouteRecordRaw[] = [
name: "Home",
component: Home,
},
{
path: "/communication",
name: "Communication",
component: Communication,
},
{
path: "/configuration",
name: "Configuration",
component: Configuration,
},
{
path: "/intelligence",
name: "Intelligence",
component: Intelligence,
},
{
path: "/operations",
name: "Operations",
component: Operations,
},
{
path: "/planning",
name: "Planning",
component: Planning,
},
{
path: "/review",
name: "Review",
component: Review,
},
{
path: "/sudo",
name: "Sudo",
component: Sudo,
},
];
const router = createRouter({

43
ts/store/communication.ts Normal file
View file

@ -0,0 +1,43 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
export const useCommunicationStore = defineStore("communication", () => {
// State
const communications = ref(null);
const loading = ref(false);
const error = ref(null);
// Actions
async function fetchCommunications() {
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(
`$${apiBase.value}/communication?$${params}`,
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
communications.value = data.communications;
// if we already had something selected, reset it using the new data
if (selectedCommunication.value) {
const matching = communications.value.filter((report) => {
return report.id === selectedCommunication.value.id;
});
if (matching.length > 0) {
selectedCommunication.value = matching[0];
}
}
} catch (err) {
console.error("Error loading communications:", err);
throw err;
}
}
});

50
ts/store/user.ts Normal file
View file

@ -0,0 +1,50 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
export const useUserStore = defineStore("user", () => {
// State
const user = ref(null);
const loading = ref(false);
const error = ref(null);
// Getters
const isAuthenticated = computed(() => user.value !== null);
const userName = computed(() => user.value?.name ?? "");
const organization = computed(() => user.value?.organization ?? "");
// Actions
async function fetchUser() {
loading.value = true;
error.value = null;
try {
const response = await fetch("/api/user/self");
if (!response.ok) throw new Error("Failed to fetch user");
user.value = await response.json();
} catch (e) {
error.value = e.message;
console.error("Error fetching user:", e);
} finally {
loading.value = false;
}
}
function clearUser() {
user.value = null;
}
return {
// State
user,
loading,
error,
// Getters
isAuthenticated,
userName,
organization,
// Actions
fetchUser,
clearUser,
};
});

View file

@ -133,8 +133,8 @@
<MapMultipoint
id="map"
ref="mapRef"
:organization-id="organizationId"
:tegola="tegolaUrl"
:organization-id="organization.id"
:tegola="organization.urls.tegola"
:xmin="serviceArea.min.x"
:ymin="serviceArea.min.y"
:xmax="serviceArea.max.x"
@ -775,16 +775,19 @@
<script setup>
import { ref, computed, onMounted, nextTick } from "vue";
import maplibregl from "maplibre-gl";
// Import your custom components
import { useCommunicationStore } from "../store/communication";
import { useUserStore } from "../store/user";
import MapMultipoint from "../components/MapMultipoint.vue";
import TimeRelative from "../components/TimeRelative.vue";
const user = useUserStore();
onMounted(() => {
communicationStore.fetchCommunications();
});
// Props
const props = defineProps({
organizationId: {
type: String,
required: true,
},
tegolaUrl: {
type: String,
required: true,
@ -871,35 +874,6 @@ function formatDate(date) {
return new Date(date).toLocaleString();
}
async function fetchCommunications() {
try {
const params = new URLSearchParams();
params.append("sort", "-created");
if (typeFilter.value) params.append("type", typeFilter.value);
const response = await fetch(`$${apiBase.value}/communication?$${params}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
communications.value = data.communications;
// if we already had something selected, reset it using the new data
if (selectedCommunication.value) {
const matching = communications.value.filter((report) => {
return report.id === selectedCommunication.value.id;
});
if (matching.length > 0) {
selectedCommunication.value = matching[0];
}
}
} catch (err) {
console.error("Error loading communications:", err);
throw err;
}
}
async function loadFromAPI() {
loading.value = true;
error.value = null;

View file

@ -173,6 +173,7 @@
<script setup lang="ts">
import { onMounted, reactive } from "vue";
import MapAggregate from "../components/MapAggregate.vue";
const dashboard = reactive({
counts: {
service_requests: 0,