Add barely-compiling views for the rest of the sidebar

No way these things actually work.
This commit is contained in:
Eli Ribble 2026-03-22 00:22:16 +00:00
parent 6422609150
commit c75c5446f7
No known key found for this signature in database
8 changed files with 3652 additions and 0 deletions

View file

@ -0,0 +1,219 @@
<template>
<div ref="mapContainer" class="map-multipoint"></div>
</template>
<script setup>
import {
ref,
onMounted,
onUnmounted,
defineProps,
defineEmits,
defineExpose,
} from "vue";
import maplibregl from "maplibre-gl";
const props = defineProps({
xmin: {
type: Number,
default: 0,
},
ymin: {
type: Number,
default: 0,
},
xmax: {
type: Number,
default: 0,
},
ymax: {
type: Number,
default: 0,
},
organizationId: {
type: Number,
default: 0,
},
tegola: {
type: String,
default: "",
},
});
const emit = defineEmits(["load"]);
const mapContainer = ref(null);
const _map = ref(null);
const _markers = ref([]);
const _preOns = ref([]);
const _bounds = () => {
let bounds = [
[props.xmin, props.ymin],
[props.xmax, props.ymax],
];
if (
props.xmin === 0 ||
props.xmax === 0 ||
props.ymin === 0 ||
props.ymax === 0
) {
bounds = [
[-125, 25],
[-70, 50],
];
}
return bounds;
};
const _initializeMap = () => {
const bounds = _bounds();
_map.value = new maplibregl.Map({
bounds: bounds,
container: mapContainer.value,
style: "https://tiles.stadiamaps.com/styles/osm_bright.json",
});
_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",
},
});
}
emit("load", { map: _map.value });
});
for (const on of _preOns.value) {
_map.value.on(...on);
}
};
// Map wrapper methods
const addLayer = (a) => {
return _map.value?.addLayer(a);
};
const addSource = (a, b) => {
return _map.value?.addSource(a, b);
};
const flyTo = (a, b) => {
return _map.value?.flyTo(a, b);
};
const getCanvas = (...args) => {
return _map.value?.getCanvas(...args);
};
const getContainer = (...args) => {
return _map.value?.getContainer(...args);
};
const jumpTo = (args) => {
return _map.value?.jumpTo(args);
};
const on = (...args) => {
if (_map.value != null) {
return _map.value.on(...args);
} else {
_preOns.value.push(args);
}
};
const once = (a, b) => {
return _map.value?.once(a, b);
};
const panTo = (a, b) => {
return _map.value?.panTo(a, b);
};
const queryRenderedFeatures = (a) => {
return _map.value?.queryRenderedFeatures(a);
};
const ClearMarkers = () => {
_markers.value.forEach((marker) => marker.remove());
};
const FitBounds = (bounds, options) => {
return _map.value?.fitBounds(bounds, options);
};
const ResetCamera = () => {
const bounds = _bounds();
FitBounds(bounds, {
linear: false,
});
};
const SetLayoutProperty = (layout, property, value) => {
return _map.value?.setLayoutProperty(layout, property, value);
};
const SetMarkers = (markers) => {
console.log("Setting map markers", markers);
_markers.value.forEach((marker) => marker.remove());
_markers.value = markers;
for (let m of markers) {
m.addTo(_map.value);
}
};
// Lifecycle
onMounted(() => {
setTimeout(() => _initializeMap(), 0);
});
onUnmounted(() => {
if (_map.value) {
_map.value.remove();
}
});
// Expose methods to parent component
defineExpose({
addLayer,
addSource,
flyTo,
getCanvas,
getContainer,
jumpTo,
on,
once,
panTo,
queryRenderedFeatures,
ClearMarkers,
FitBounds,
ResetCamera,
SetLayoutProperty,
SetMarkers,
});
</script>
<style scoped>
@import url("//unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css");
.map-multipoint {
height: 100%;
width: 100%;
}
</style>

View file

@ -0,0 +1,102 @@
<template>
<span class="time-relative">{{ relativeTime }}</span>
</template>
<script lang="ts">
import { defineComponent } from "vue";
/**
* TimeRelative component that displays relative time
* Usage: <TimeRelative time="2024-01-01T12:00:00Z" />
*/
export default defineComponent({
name: "TimeRelative",
props: {
time: {
type: String,
default: "",
},
},
data() {
return {
relativeTime: "" as string,
intervalId: null as number | null,
};
},
watch: {
time: {
immediate: true,
handler() {
this.updateTime();
},
},
},
mounted() {
this.updateTime();
// Update every 60 seconds
this.intervalId = window.setInterval(() => this.updateTime(), 60000);
},
unmounted() {
if (this.intervalId !== null) {
clearInterval(this.intervalId);
}
},
methods: {
updateTime(): void {
if (this.time) {
this.relativeTime = this.formatRelativeTime(this.time);
} else {
this.relativeTime = "";
}
},
formatRelativeTime(timestamp: string): string {
const now = new Date();
const date = new Date(timestamp);
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
// Time units in seconds
const minute = 60;
const hour = minute * 60;
const day = hour * 24;
const week = day * 7;
const month = day * 30;
const year = day * 365;
if (diffInSeconds < minute) {
return "just now";
} else if (diffInSeconds < hour) {
const minutes = Math.floor(diffInSeconds / minute);
return `${minutes} ${minutes === 1 ? "min" : "min"} ago`;
} else if (diffInSeconds < day) {
const hours = Math.floor(diffInSeconds / hour);
return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
} else if (diffInSeconds < week) {
const days = Math.floor(diffInSeconds / day);
return `${days} ${days === 1 ? "day" : "days"} ago`;
} else if (diffInSeconds < month) {
const weeks = Math.floor(diffInSeconds / week);
return `${weeks} ${weeks === 1 ? "week" : "weeks"} ago`;
} else if (diffInSeconds < year) {
const months = Math.floor(diffInSeconds / month);
return `${months} ${months === 1 ? "month" : "months"} ago`;
} else {
const years = Math.floor(diffInSeconds / year);
return `${years} ${years === 1 ? "year" : "years"} ago`;
}
},
},
});
</script>
<style scoped>
.time-relative {
/* Add your styles here */
}
</style>

1204
ts/view/Communication.vue Normal file

File diff suppressed because it is too large Load diff

165
ts/view/Configuration.vue Normal file
View file

@ -0,0 +1,165 @@
<template>
<div class="container py-5">
<div class="row mb-4">
<div class="col">
<h1 class="display-5 mb-3">Settings</h1>
<p class="text-muted lead">
Configure your organization's preferences and integrations
</p>
</div>
</div>
<div class="row g-4">
<!-- User Management Card -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card settings-card border-0 shadow-sm">
<div class="card-body p-4">
<div class="settings-icon icon-users">
<i class="bi bi-people-fill"></i>
</div>
<h2 class="h4 mb-2">User Management</h2>
<p class="text-muted mb-4">
Manage staff accounts, roles, and permissions for your
organization.
</p>
<div class="d-flex justify-content-between align-items-center">
<a
href="{{ .URL.Configuration.User }}"
class="btn btn-outline-primary"
>
Manage Users
<i class="bi bi-arrow-right ms-1"></i>
</a>
</div>
</div>
</div>
</div>
<!-- Pesticide Products Card -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card settings-card border-0 shadow-sm">
<div class="card-body p-4">
<div class="settings-icon icon-pesticides">
<i class="bi bi-droplet-fill"></i>
</div>
<h2 class="h4 mb-2">Pesticide Products</h2>
<p class="text-muted mb-4">
Configure products, application rates, and field recommendations.
</p>
<div class="d-flex justify-content-between align-items-center">
<a
href="{{ .URL.Configuration.Pesticide }}"
class="btn btn-outline-success"
>
Manage Products
<i class="bi bi-arrow-right ms-1"></i>
</a>
</div>
</div>
</div>
</div>
<!-- Integrations Card -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card settings-card border-0 shadow-sm">
<div class="card-body p-4">
<div class="settings-icon icon-integrations">
<i class="bi bi-gear-wide-connected"></i>
</div>
<h2 class="h4 mb-2">Integrations</h2>
<p class="text-muted mb-4">
Configure connections with FieldSeeker, VectorSurv, and other
services.
</p>
<div class="d-flex justify-content-between align-items-center">
<a
href="{{ .URL.Configuration.Integration }}"
class="btn btn-outline-primary"
>
Manage Integrations
<i class="bi bi-arrow-right ms-1"></i>
</a>
</div>
</div>
</div>
</div>
<!-- Organization Card -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card settings-card border-0 shadow-sm">
<div class="card-body p-4">
<div class="settings-icon icon-equipment">
<i class="bi bi-globe-europe-africa"></i>
</div>
<h2 class="h4 mb-2">Organization</h2>
<p class="text-muted mb-4">
Manage your organization service area and information.
</p>
<div class="d-flex justify-content-between align-items-center">
<a
href="{{ .URL.Configuration.Organization }}"
class="btn btn-outline-danger"
>
Manage Organization
<i class="bi bi-arrow-right ms-1"></i>
</a>
</div>
</div>
</div>
</div>
<!-- Import Card -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card settings-card border-0 shadow-sm">
<div class="card-body p-4">
<div class="settings-icon icon-notifications">
<i class="bi bi-upload"></i>
</div>
<h2 class="h4 mb-2">Uploads</h2>
<p class="text-muted mb-4">
Upload files (spreadsheets, scans, notes) to make the data
available to Nidus
</p>
<div class="d-flex justify-content-between align-items-center">
<a
class="btn btn-outline-warning"
href="{{ .URL.Configuration.Upload }}"
>
Manage Uploads
<i class="bi bi-arrow-right ms-1"></i>
</a>
</div>
</div>
</div>
</div>
<!-- General Settings Card -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card settings-card border-0 shadow-sm">
<div class="card-body p-4">
<div class="settings-icon icon-general">
<i class="bi bi-sliders"></i>
</div>
<h2 class="h4 mb-2">General Settings</h2>
<p class="text-muted mb-4">
Configure organization details, branding, and system preferences.
</p>
<div class="d-flex justify-content-between align-items-center">
<a class="btn btn-outline-secondary disabled" disabled>
Manage Settings
<i class="bi bi-arrow-right ms-1"></i>
</a>
</div>
</div>
</div>
</div>
</div>
<div class="mt-5 text-center text-muted">
<p class="small">
<i class="bi bi-shield-lock me-1"></i>
All changes made in settings are logged for audit purposes
</p>
</div>
</div>
</template>

693
ts/view/Operations.vue Normal file
View file

@ -0,0 +1,693 @@
<template>
<div class="operations-command-center">
<!-- HEADER -->
<div class="row mb-3 align-items-center">
<div class="col-md-6">
<h4 class="fw-bold mb-0">Operations Command Center</h4>
</div>
<div class="col-md-6 text-end">
<button
class="btn btn-outline-primary me-2"
@click="addEmergentAssignment"
>
Add Emergent Assignment
</button>
<button class="btn btn-outline-danger" @click="closeDay">
Close Day
</button>
</div>
</div>
<!-- MODE TOGGLE -->
<ul class="nav nav-tabs mode-toggle mb-4" role="tablist">
<li class="nav-item">
<button
class="nav-link"
:class="{ active: activeTab === 'planning' }"
@click="activeTab = 'planning'"
>
Planning Mode
</button>
</li>
<li class="nav-item">
<button
class="nav-link"
:class="{ active: activeTab === 'live' }"
@click="activeTab = 'live'"
>
Live Mode
</button>
</li>
</ul>
<div class="tab-content">
<!-- ================= PLANNING MODE ================= -->
<div
class="tab-pane fade"
:class="{ 'show active': activeTab === 'planning' }"
>
<div class="row mb-3">
<!-- LEFT: ASSIGNMENTS -->
<div class="col-lg-3">
<div class="card">
<div
class="card-header d-flex justify-content-between align-items-center fw-semibold"
>
<span>Assignments & Work Requests</span>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
v-model="selectAllAssignments"
@change="toggleAllAssignments"
/>
<label class="form-check-label small">Select All</label>
</div>
</div>
<div class="card-body scroll-panel">
<input
class="form-control form-control-sm mb-2"
placeholder="Filter by section, equipment, expertise"
v-model="assignmentFilter"
/>
<div class="list-group">
<div
v-for="assignment in filteredAssignments"
:key="assignment.id"
class="list-group-item d-flex"
>
<div class="form-check me-2">
<input
class="form-check-input"
type="checkbox"
v-model="assignment.selected"
/>
</div>
<div>
<div class="d-flex justify-content-between">
<strong>{{ assignment.name }}</strong>
<span
class="badge"
:class="{
'bg-primary': assignment.status === 'Planned',
'bg-warning text-dark':
assignment.status === 'Emergent',
}"
>
{{ assignment.status }}
</span>
</div>
<small>{{ assignment.details }}</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- CENTER: MAP -->
<div class="col-lg-6">
<div class="card">
<div class="card-header fw-semibold">Routing Map</div>
<div class="map-placeholder" ref="planningMap">
Map: Selected Assignments, Selected Technicians, Proposed Routes
</div>
<div
class="card-footer d-flex justify-content-between align-items-center"
>
<div
class="selection-counter small"
:class="selectionCounterClass"
>
{{ selectedAssignmentsCount }} Assignments Selected ·
{{ selectedTechniciansCount }} Technicians Selected
</div>
<div>
<button class="btn btn-success" @click="computeOptimalRoutes">
Compute Optimal Routes
</button>
<button
class="btn btn-outline-primary"
@click="manualAssignment"
>
Manual Assignment
</button>
</div>
</div>
</div>
</div>
<!-- RIGHT: TECHNICIANS -->
<div class="col-lg-3">
<div class="card">
<div
class="card-header d-flex justify-content-between align-items-center fw-semibold"
>
<span>Technicians</span>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
v-model="selectAllTechnicians"
@change="toggleAllTechnicians"
/>
<label class="form-check-label small">Select All</label>
</div>
</div>
<div class="card-body scroll-panel">
<input
class="form-control form-control-sm mb-2"
placeholder="Filter technicians"
v-model="technicianFilter"
/>
<div class="list-group">
<div
v-for="technician in filteredTechnicians"
:key="technician.id"
class="list-group-item d-flex"
:class="{ overload: technician.overload }"
>
<div class="form-check me-2">
<input
class="form-check-input"
type="checkbox"
v-model="technician.selected"
/>
</div>
<div>
<div class="d-flex justify-content-between">
<strong>{{ technician.name }}</strong>
<span>
<span
class="status-dot"
:class="{
'bg-success': technician.status === 'Available',
'bg-warning': technician.status === 'In Field',
}"
></span>
{{ technician.status }}
</span>
</div>
<small>{{ technician.details }}</small>
<div class="mt-2">
<label class="form-label form-label-sm mb-1">
Assigned Vehicle
</label>
<select
class="form-select form-select-sm"
v-model="technician.vehicle"
>
<option
v-for="vehicle in vehicles"
:key="vehicle.id"
:value="vehicle.id"
>
{{ vehicle.name }}
</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ROUTES LIST (STACKED) -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header fw-semibold">Proposed Routes</div>
<div class="card-body">
<div
v-for="route in proposedRoutes"
:key="route.id"
class="card route-card p-3 mb-3"
>
<strong>{{ route.title }}</strong
><br />
<small>{{ route.summary }}</small>
<div class="mt-2">
<button
class="btn btn-sm btn-outline-secondary"
@click="viewAssignments(route.id)"
>
View Assignments
</button>
<button
class="btn btn-sm btn-outline-primary"
@click="modifyRoute(route.id)"
>
Modify Route
</button>
<button
class="btn btn-sm btn-outline-secondary"
@click="shiftAssignment(route.id)"
>
Shift Assignment
</button>
<button
class="btn btn-sm btn-outline-secondary"
@click="swapTechnician(route.id)"
>
Swap Technician
</button>
</div>
</div>
</div>
<div class="card-footer text-end">
<button
class="btn btn-primary send-routes-btn"
@click="sendRoutes"
>
Send Routes to Technicians and Begin Live Operations
</button>
</div>
</div>
</div>
</div>
</div>
<!-- ================= LIVE MODE ================= -->
<div
class="tab-pane fade"
:class="{ 'show active': activeTab === 'live' }"
>
<div class="row mb-3">
<!-- LEFT: ASSIGNMENTS IN ROUTE ORDER -->
<div class="col-lg-3">
<div class="card">
<div class="card-header fw-semibold">
Assignments in Route Order
</div>
<div class="card-body scroll-panel">
<div class="alert alert-warning">
<strong>Unassigned Assignments</strong><br />
{{ unassignedCount }} awaiting routing
</div>
<ul class="list-group list-group-flush">
<li
v-for="assignment in liveAssignments"
:key="assignment.id"
class="list-group-item"
>
<strong>{{ assignment.name }}</strong
><br />
{{ assignment.status }}
</li>
</ul>
</div>
</div>
</div>
<!-- CENTER: LIVE MAP -->
<div class="col-lg-6">
<div class="live-map" ref="liveMap">
Live Map: Active Routes, Technician Position, Route Progress
</div>
</div>
<!-- RIGHT: TECHNICIAN STATUS -->
<div class="col-lg-3">
<div class="card">
<div class="card-header fw-semibold">Technician Status</div>
<div class="card-body scroll-panel">
<div class="list-group">
<div
v-for="technician in liveTechnicians"
:key="technician.id"
class="list-group-item"
>
<div class="d-flex justify-content-between">
<strong>{{ technician.name }}</strong>
<span
class="badge"
:class="{
'bg-success': technician.liveStatus === 'On Track',
'bg-danger':
technician.liveStatus === 'Support Requested',
}"
>
{{ technician.liveStatus }}
</span>
</div>
<small>{{ technician.liveDetails }}</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ROUTE INTELLIGENCE -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header fw-semibold">
Active Routes Intelligence
</div>
<div class="card-body">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Technician</th>
<th>Assignments</th>
<th>Estimated Completion</th>
<th>Remaining Time</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="route in activeRoutes" :key="route.id">
<td>{{ route.technician }}</td>
<td>{{ route.assignmentCount }}</td>
<td>{{ route.estimatedCompletion }}</td>
<td>{{ route.remainingTime }}</td>
<td>
<span
class="badge"
:class="{
'bg-success': route.status === 'On Track',
'bg-danger': route.status === 'Blocked',
}"
>
{{ route.status }}
</span>
</td>
<td>
<button
class="btn btn-sm btn-outline-secondary"
@click="viewRoute(route.id)"
>
View Route
</button>
<button
class="btn btn-sm btn-outline-primary"
@click="reallocateOrAssist(route.id)"
>
{{
route.status === "Blocked" ? "Assist" : "Reallocate"
}}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
// Active tab state
const activeTab = ref("planning");
// Planning Mode - Assignments
const assignmentFilter = ref("");
const selectAllAssignments = ref(false);
const assignments = ref([
{
id: 1,
name: "Larval Habitat Inspection",
status: "Planned",
details: "Residential · Backpack Blower",
selected: false,
},
{
id: 2,
name: "Green Pool Treatment",
status: "Emergent",
details: "Residential · Larvicide · Access Clearance",
selected: false,
},
]);
const filteredAssignments = computed(() => {
if (!assignmentFilter.value) return assignments.value;
const filter = assignmentFilter.value.toLowerCase();
return assignments.value.filter(
(a) =>
a.name.toLowerCase().includes(filter) ||
a.details.toLowerCase().includes(filter),
);
});
const selectedAssignmentsCount = computed(() => {
return assignments.value.filter((a) => a.selected).length;
});
const toggleAllAssignments = () => {
assignments.value.forEach((a) => (a.selected = selectAllAssignments.value));
};
// Planning Mode - Technicians
const technicianFilter = ref("");
const selectAllTechnicians = ref(false);
const technicians = ref([
{
id: 1,
name: "Technician A",
status: "Available",
details: "Residential · ULV · Backpack",
vehicle: 1,
selected: false,
overload: false,
},
{
id: 2,
name: "Technician B",
status: "In Field",
details: "Agricultural · Capacity Exceeded",
vehicle: 2,
selected: false,
overload: true,
},
]);
const vehicles = ref([
{ id: 1, name: "Truck 12 · ULV · Backpack · Larvicide Kit" },
{ id: 2, name: "ATV 3 · Dipper · Granular Spreader" },
{ id: 3, name: "Reserve Vehicle · Minimal Equipment" },
]);
const filteredTechnicians = computed(() => {
if (!technicianFilter.value) return technicians.value;
const filter = technicianFilter.value.toLowerCase();
return technicians.value.filter(
(t) =>
t.name.toLowerCase().includes(filter) ||
t.details.toLowerCase().includes(filter),
);
});
const selectedTechniciansCount = computed(() => {
return technicians.value.filter((t) => t.selected).length;
});
const toggleAllTechnicians = () => {
technicians.value.forEach((t) => (t.selected = selectAllTechnicians.value));
};
// Selection counter class
const selectionCounterClass = computed(() => {
const assignmentCount = selectedAssignmentsCount.value;
const technicianCount = selectedTechniciansCount.value;
if (assignmentCount > 0 && technicianCount > 0) {
return "valid";
} else if (assignmentCount > 0 || technicianCount > 0) {
return "warning";
}
return "invalid";
});
// Proposed Routes
const proposedRoutes = ref([
{
id: 1,
title: "Route: Technician A",
summary: "5 Assignments · Est. 4.5 hrs · Equipment: Backpack",
},
{
id: 2,
title: "Route: Technician B",
summary: "6 Assignments · Est. 6 hrs · Equipment: ATV",
},
]);
// Live Mode - Assignments
const unassignedCount = ref(2);
const liveAssignments = ref([
{ id: 1, name: "Green Pool Reinspection", status: "Communication Pending" },
{ id: 2, name: "Storm Drain Treatment", status: "In Progress" },
]);
// Live Mode - Technicians
const liveTechnicians = ref([
{
id: 1,
name: "Technician A",
liveStatus: "On Track",
liveDetails: "72% Complete · 1.5 hrs Remaining",
},
{
id: 2,
name: "Technician C",
liveStatus: "Support Requested",
liveDetails: "Equipment Issue",
},
]);
// Live Mode - Active Routes
const activeRoutes = ref([
{
id: 1,
technician: "Technician A",
assignmentCount: 5,
estimatedCompletion: "3:45 PM",
remainingTime: "1 hr 30 min",
status: "On Track",
},
{
id: 2,
technician: "Technician C",
assignmentCount: 4,
estimatedCompletion: "4:30 PM",
remainingTime: "2 hrs",
status: "Blocked",
},
]);
// Map refs
const planningMap = ref(null);
const liveMap = ref(null);
// Methods
const addEmergentAssignment = () => {
console.log("Add emergent assignment");
};
const closeDay = () => {
console.log("Close day");
};
const computeOptimalRoutes = () => {
console.log("Computing optimal routes...");
};
const manualAssignment = () => {
console.log("Manual assignment mode");
};
const viewAssignments = (routeId) => {
console.log("View assignments for route:", routeId);
};
const modifyRoute = (routeId) => {
console.log("Modify route:", routeId);
};
const shiftAssignment = (routeId) => {
console.log("Shift assignment for route:", routeId);
};
const swapTechnician = (routeId) => {
console.log("Swap technician for route:", routeId);
};
const sendRoutes = () => {
console.log("Sending routes to technicians...");
activeTab.value = "live";
};
const viewRoute = (routeId) => {
console.log("View route:", routeId);
};
const reallocateOrAssist = (routeId) => {
console.log("Reallocate/Assist route:", routeId);
};
// Initialize map when component is mounted
onMounted(() => {
// Initialize MapLibre GL maps here if needed
// You'll need to import maplibre-gl separately
console.log("Component mounted, initialize maps");
});
</script>
<style scoped>
.card {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-right: 6px;
}
.overload {
border-left: 4px solid #dc3545;
}
.map-placeholder {
height: 520px;
background: #e9ecef;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: #6c757d;
}
.live-map {
height: 620px;
background: #dee2e6;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: #6c757d;
}
.scroll-panel {
max-height: 500px;
overflow-y: auto;
}
.mode-toggle .nav-link {
font-weight: 600;
}
.route-card {
border-left: 4px solid #0d6efd;
}
.send-routes-btn {
font-size: 1.1rem;
padding: 0.75rem 1.5rem;
}
.selection-counter.valid {
color: #198754;
}
.selection-counter.invalid {
color: #dc3545;
}
.selection-counter.warning {
color: #fd7e14;
}
</style>

786
ts/view/Planning.vue Normal file
View file

@ -0,0 +1,786 @@
<template>
<div class="container-fluid py-3">
<!-- Header -->
<div class="row mb-3">
<div class="col">
<h3 class="mb-1">Daily Planning Workbench</h3>
<div class="text-muted small">
Signals and leads enter from the left, are investigated in the center,
and transformed into structured field assignments using tools on the
right.
</div>
</div>
</div>
<div class="row g-3">
<!-- LEFT: Incoming Signals & Leads -->
<div class="col-xl-3">
<div class="card shadow-sm h-100">
<div class="card-header bg-white pane-header">
Incoming Signals & Leads
<span
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 && 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>
<script setup>
import { ref, onMounted, nextTick } from "vue";
// Props
const props = defineProps({
organizationId: {
type: String,
required: true,
},
tegolaUrl: {
type: String,
required: true,
},
urlTiles: {
type: String,
required: true,
},
serviceArea: {
type: Object,
required: true,
default: () => ({ xmin: 0, ymin: 0, xmax: 0, ymax: 0 }),
},
});
// Refs
const mapMultipoint = ref(null);
const mapTile = ref(null);
// State
const apiBase = ref("/api");
const creating = ref(false);
const error = ref(null);
const planFollowups = ref([]);
const leads = ref([]);
const loading = ref(false);
const poolLocations = ref({});
const showMapTile = ref(false);
const selectedSignals = ref([]);
const signals = ref([]);
const filters = ref({
species: "",
type: "",
sort: "newest",
});
// Helper functions (outside component)
const getBoundingBox = (points) => {
if (!points || points.length === 0) {
return null;
}
let minLat = points[0].latitude;
let maxLat = points[0].latitude;
let minLng = points[0].longitude;
let maxLng = points[0].longitude;
for (const point of points) {
if (point.latitude < minLat) minLat = point.latitude;
if (point.latitude > maxLat) maxLat = point.latitude;
if (point.longitude < minLng) minLng = point.longitude;
if (point.longitude > maxLng) maxLng = point.longitude;
}
return new window.maplibregl.LngLatBounds(
new window.maplibregl.LngLat(minLng, minLat),
new window.maplibregl.LngLat(maxLng, maxLat),
);
};
const shortAddress = (a) => {
if (!a) return "";
return `${a.number} ${a.street}, ${a.locality}`;
};
const updateMap = (signals) => {
if (!mapMultipoint.value) return;
const locations = signals.map((s) => s.location);
const markers = locations.map((l) =>
new window.maplibregl.Marker({
color: "#FF0000",
draggable: false,
}).setLngLat([l.longitude, l.latitude]),
);
mapMultipoint.value.SetMarkers(markers);
const bounds = getBoundingBox(locations);
if (bounds != null) {
mapMultipoint.value.FitBounds(bounds, {
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
const loadData = async () => {
loading.value = true;
error.value = null;
try {
await Promise.all([loadSignals(), loadLeads()]);
} catch (err) {
error.value = err.message;
console.error("Error loading data:", err);
} finally {
loading.value = false;
}
};
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 () => {
try {
const response = await fetch(`${apiBase.value}/plan-followups`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
planFollowups.value = data.followups || data;
} catch (err) {
console.error("Error loading plan followups:", err);
throw err;
}
};
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 = () => {
selectedSignals.value = [];
};
const createLead = async () => {
if (selectedSignals.value.length === 0) return;
creating.value = true;
try {
const response = await fetch(`${apiBase.value}/leads`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
pool_locations: poolLocations.value,
signal_ids: selectedSignals.value.map((s) => s.id),
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`,
);
}
const newLead = await response.json();
leads.value.unshift(newLead);
clearSelection();
await loadSignals();
} catch (err) {
console.error("Error creating lead:", err);
alert(`Failed to create lead: ${err.message}`);
} finally {
creating.value = false;
}
};
const markAsAddressed = async () => {
if (selectedSignals.value.length === 0) return;
try {
const response = await fetch(`${apiBase.value}/signal/mark-addressed`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
signal_ids: selectedSignals.value.map((s) => s.id),
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
signals.value = signals.value.filter(
(signal) => !selectedSignals.value.some((s) => s.id === signal.id),
);
clearSelection();
alert("Signals marked as addressed");
} catch (err) {
console.error("Error marking signals as addressed:", err);
alert(`Failed to mark signals: ${err.message}`);
}
};
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 index = selectedSignals.value.findIndex((s) => s.id === signal.id);
if (index > -1) {
selectedSignals.value.splice(index, 1);
} else {
selectedSignals.value.push(signal);
}
updateMap(selectedSignals.value);
showMapTile.value = selectedSignals.value.reduce(
(accumulator, current) => accumulator || current.type === "flyover pool",
false,
);
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
onMounted(() => {
loadData();
// Subscribe to SSE events
if (window.SSEManager) {
window.SSEManager.subscribe("*", (e) => {
if (e.resource === "sync:signal") {
loadData();
}
});
}
// Configure map multipoint
const map = mapMultipoint.value;
if (map) {
map.on("load", () => {
map.addLayer({
id: "parcel",
minzoom: 14,
paint: {
"line-color": "#0f0",
},
source: "tegola",
"source-layer": "parcel",
type: "line",
});
map.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",
});
console.log("Added parcel and signal layers");
});
}
});
</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>

59
ts/view/Review.vue Normal file
View file

@ -0,0 +1,59 @@
<template>
<div class="row">
<div class="col-md-4">
<a class="card-link" href="/review/pool">
<div class="card" style="width: 100%">
<img
alt="overhead pool"
class="card-img-top"
src="/static/img/pool-overhead.jpg"
/>
<div class="card-body">
<h1>Imported Pools</h1>
<p>
Pools that have been imported with their aerial imagery appear
here waiting for human review before being added to the system
</p>
</div>
</div>
</a>
</div>
<div class="col-md-4">
<a class="card-link" href="/review/site">
<div class="card" style="width: 100%">
<img
alt="pool"
class="card-img-top"
src="/static/img/insecticide-application.jpg"
/>
<div class="card-body">
<h1>Sites</h1>
<p>
Areas that we're tracking for potentially becoming breeding
locations.
</p>
</div>
</div>
</a>
</div>
</div>
</template>
<style>
.card-link .card-img-top {
height: 200px;
object-fit: cover;
object-position: center;
}
.card-link .card {
height: 100%;
}
.card-link {
text-decoration: none;
color: inherit;
display: block;
height: 100%;
}
</style>

424
ts/view/Sudo.vue Normal file
View file

@ -0,0 +1,424 @@
<template>
<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="forwardEmailRMOAddress">
{{ forwardEmailRMOAddress }}
</option>
<option :value="forwardEmailNidusAddress">
{{ 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="organizationID"
/>
</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>
</template>
<script setup lang="ts"></script>