Add barely-compiling views for the rest of the sidebar
No way these things actually work.
This commit is contained in:
parent
6422609150
commit
c75c5446f7
8 changed files with 3652 additions and 0 deletions
219
ts/components/MapMultipoint.vue
Normal file
219
ts/components/MapMultipoint.vue
Normal 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>
|
||||
102
ts/components/TimeRelative.vue
Normal file
102
ts/components/TimeRelative.vue
Normal 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
1204
ts/view/Communication.vue
Normal file
File diff suppressed because it is too large
Load diff
165
ts/view/Configuration.vue
Normal file
165
ts/view/Configuration.vue
Normal 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
693
ts/view/Operations.vue
Normal 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
786
ts/view/Planning.vue
Normal 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
59
ts/view/Review.vue
Normal 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
424
ts/view/Sudo.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue