Get some planning buttons wired up

This commit is contained in:
Eli Ribble 2026-03-25 21:45:13 -07:00
parent ef412b28ec
commit bf2a7582fa
No known key found for this signature in database
12 changed files with 568 additions and 25 deletions

View file

@ -13,5 +13,9 @@
import { useUserStore } from "../store/user";
import MapProxiedArcgisTile from "@/components/MapProxiedArcgisTile.vue";
interface Props {
pool: Pool;
}
const props = defineProps<Props>();
const user = useUserStore();
</script>

View file

@ -45,11 +45,11 @@ const markers = ref<Map<string, maplibrgl.Marker>>(new Map())
// Watch for latitude/longitude changes
watch(
() => [props.latitude, props.longitude],
([newLat, newLng]) => {
() => [props.location],
([newLocation]) => {
if (map.value) {
map.value.jumpTo({
center: [newLng, newLat],
center: [newLocation.longitude, newLocation.latitude],
zoom: 19,
});
}
@ -61,7 +61,7 @@ const initializeMap = () => {
try {
map.value = new maplibregl.Map({
center: [props.longitude, props.latitude],
center: [props.location.longitude, props.location.latitude],
container: mapContainer.value,
style: "https://tiles.stadiamaps.com/styles/osm_bright.json",
zoom: 19,

View file

@ -19,7 +19,7 @@
<button
class="btn btn-outline-primary tool-button"
:disabled="selectedSignalIDs.size === 0 || creating"
@click="createLead()"
@click="emit('doCreateLead')"
>
<span v-if="!creating">Create New Lead from Selection</span>
<span v-else>
@ -30,13 +30,14 @@
<button
class="btn btn-outline-secondary tool-button"
:disabled="selectedSignalIDs.size === 0"
@click="emit('doAddToLead')"
>
Add Signals to Existing Lead
</button>
<button
class="btn btn-outline-secondary tool-button"
:disabled="selectedSignalIDs.size === 0"
@click="markAsAddressed()"
@click="emit('doMarkSignalAddressed')"
>
Mark Signal as Addressed
</button>
@ -46,13 +47,13 @@
<div class="mb-3">
<div class="text-muted small mb-2">Lead Field Assignment</div>
<button class="btn btn-outline-success tool-button">
<button class="btn btn-outline-success tool-button" @click="emit('doCreateProposedAssignment')">
Create Proposed Assignment
</button>
<button class="btn btn-outline-secondary tool-button">
<button class="btn btn-outline-secondary tool-button" @click="emit('doAddLeadsToAssignment')">
Add Leads to Existing Assignment
</button>
<button class="btn btn-outline-secondary tool-button">
<button class="btn btn-outline-secondary tool-button" @click="emit('doSplitLead')">
Split Lead
</button>
</div>
@ -61,11 +62,11 @@
<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">
<button class="btn btn-outline-dark tool-button" @click="emit('doSetPriority')">Set Priority</button>
<button class="btn btn-outline-dark tool-button" @click="emit('doEstimateEffort')">
Estimate Effort
</button>
<button class="btn btn-outline-dark tool-button">
<button class="btn btn-outline-dark tool-button" @click="emit('doSendToOperations')">
Send to Operations
</button>
</div>
@ -73,9 +74,21 @@
</div>
</template>
<script setup lang="ts">
interface Emits {
(e: "doAddToLead"): void;
(e: "doAddLeadsToAssignment"): void;
(e: "doCreateLead"): void;
(e: "doCreateProposedAssignment"): void;
(e: "doEstimateEffort"): void;
(e: "doMarkSignalAddressed"): void;
(e: "doSetPriority"): void;
(e: "doSendToOperations"): void;
(e: "doSplitLead"): void;
}
interface Props {
creating: boolean;
selectedSignalIDs: Set<int>;
}
const emit = defineEmits<Emits>();
const props = defineProps<Props>();
</script>

View file

@ -70,8 +70,7 @@
:organization-id="user.organization.id"
:tegola="user.urls.tegola"
:url-tiles="user.urls.tile"
:latitude="selectedSignalLocation()?.latitude ?? 0.0"
:longitude="selectedSignalLocation()?.longitude ?? 0.0"
:location="selectedSignalLocation()"
@map-click="updateSignalLocation"
>
</MapProxiedArcgisTile>
@ -131,10 +130,14 @@ const selectedSignalLocation = () => {
}
return accumulator;
}, null);
return first_pool?.location;
const loc = first_pool?.location;
return loc || {
latitude: 0,
longitude: 0,
}
};
const showMapTile = () => {
return props.selectedSignals.value
return selectedSignalLocation() && props.selectedSignals.value
.values()
.reduce(
(accumulator, current) => accumulator || current.type === "flyover pool",

View file

@ -1,7 +1,7 @@
<template>
<TimeRelative :time="signal.created"></TimeRelative>
<p>{{ shortAddress(signal.address) }}</p>
<div v-if="signal.type == 'flyover pool'">
<div v-if="signal.type == 'flyover pool' && signal.pool">
<FlyoverPoolCard :pool="signal.pool"/>
</div>
<div v-else-if="signal.type == 'publicreport nuisance'">

View file

@ -138,7 +138,7 @@
:class="{ selected: isSelected(signal.id) }"
@click="toggleSignal(signal)"
>
<PlanningColumnListEntry :signal="signal"/>
<PlanningColumnListEntry :selected="selectedSignalIDs.has(signal.id)" :signal="signal"/>
</div>
</div>
</div>

View file

@ -1,5 +1,11 @@
<style scoped>
.selected {
background-color: $primary;
}
</style>
<template>
<div class="row">
<div class="row" :class="selected ? 'selected' : ''">
<div class="col-1">
<i class="bi" :class="icon(signal)"></i>
</div>
@ -17,6 +23,7 @@
<script setup lang="ts">
import { shortAddress } from "../format";
interface Props {
selected: boolean;
signal: Signal;
};
const props = defineProps<Props>();

View file

@ -10,6 +10,7 @@ import ConfigurationPesticide from "./view/configuration/Pesticide.vue";
import ConfigurationPesticideAdd from "./view/configuration/PesticideAdd.vue";
import ConfigurationRoot from "./view/configuration/Root.vue";
import ConfigurationUpload from "./view/configuration/Upload.vue";
import ConfigurationUploadDetail from "./view/configuration/UploadDetail.vue";
import ConfigurationUploadPool from "./view/configuration/UploadPool.vue";
import ConfigurationUploadPoolFlyover from "./view/configuration/UploadPoolFlyover.vue";
import ConfigurationUser from "./view/configuration/User.vue";
@ -79,6 +80,13 @@ const routes: RouteRecordRaw[] = [
component: ConfigurationUpload,
meta: { requiresAuth: true, showSidebar: true },
},
{
component: ConfigurationUploadDetail,
meta: { requiresAuth: true, showSidebar: true },
name: "Upload Detail",
path: "/configuration/upload/:id",
props: true,
},
{
path: "/configuration/upload/pool",
name: "Pool Upload",
@ -159,7 +167,7 @@ const router = createRouter({
});
// Global navigation guard
router.beforeEach(async (to, from, next) => {
router.beforeEach(async (to, from) => {
const requiresAuth = to.matched.some(record => record.meta.requiresAuth);
if (requiresAuth) {
@ -167,16 +175,14 @@ router.beforeEach(async (to, from, next) => {
// Check if user is authenticated (could be an API call)
const isAuthenticated = await apiClient.isAuthenticated();
if (!isAuthenticated) {
next('/signin');
return '/signin';
} else {
next();
return
}
} catch (error) {
console.log("check auth failed");
next('/signin');
return '/signin';
}
} else {
next();
}
});

60
ts/store/upload.ts Normal file
View file

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

View file

@ -40,6 +40,15 @@
</template>
<template #right>
<PlanningColumnAction
@doAddToLead="doAddToLead"
@doAddLeadsToAssignment="doAddLeadsToAssignment"
@doCreateLead="doCreateLead"
@doCreateProposedAssignment="doCreateProposedAssignment"
@doEstimateEffort="doEstimateEffort"
@doMarkSignalAddressed="doMarkSignalAddressed"
@doSetPriority="doSetPriority"
@doSendToOperations="doSendToOperations"
@doSplitLead="doSplitLead"
:creating="creating"
:selectedSignalIDs="selectedSignalIDs"
/>
@ -74,6 +83,33 @@ const selectedSignalIDs = ref(new Set<int>([]));
const signal = useSignalStore();
const user = useUserStore();
function doAddToLead() {
console.log("doAddToLead");
}
function doAddLeadsToAssignment() {
console.log("doAddLeadsToAssignment");
}
function doCreateLead() {
console.log("doCreateLead");
}
function doCreateProposedAssignment() {
console.log("doCreateProposedAssignment");
}
function doEstimateEffort() {
console.log("doEstimateEffort");
}
function doMarkSignalAddressed() {
console.log("doMarkSignalAddressed");
}
function doSetPriority() {
console.log("doSetPriority");
}
function doSendToOperations() {
console.log("doSendToOperations");
}
function doSplitLead() {
console.log("doSplitLead");
}
// Helper functions (outside component)
const getBoundingBox = (points) => {
if (!points || points.length === 0) {

View file

@ -0,0 +1,410 @@
<style scoped>
.results-container {
max-width: 1400px;
}
.summary-card {
transition: transform 0.2s;
}
.summary-card:hover {
transform: translateY(-5px);
}
.has-error {
background-color: #fff3cd;
}
.badge.status,
.badge.condition {
font-size: 0.875rem;
padding: 0.35em 0.65em;
}
.table-responsive {
max-height: 600px;
overflow-y: auto;
}
thead tr.header {
position: sticky;
top: 0;
z-index: 10;
background-color: #f8f9fa;
}
#map {
height: 400px;
width: 100%;
}
</style>
<template>
<div class="container mt-4 results-container">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Upload Results: {{ upload?.name }}</h2>
<span class="badge rounded-pill" :class="upload?.status">
<i class="bi me-1" :class="getUploadStatusIcon(upload?.status)"></i>
{{ getUploadStatusDisplay(upload?.status) }}
</span>
</div>
<div class="row mb-4">
<div class="col-md-4">
<div class="card summary-card h-100 border-primary">
<div class="card-body text-center">
<h1 class="display-4 text-primary">
{{ upload?.countExisting }}
</h1>
<h5>Existing Pools</h5>
<p class="text-muted">Matches found in previous records</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card summary-card h-100 border-success">
<div class="card-body text-center">
<h1 class="display-4 text-success">{{ upload?.countNew }}</h1>
<h5>New Pools</h5>
<p class="text-muted">Not found in existing records</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card summary-card h-100 border-warning">
<div class="card-body text-center">
<h1 class="display-4 text-warning">{{ upload?.countOutside }}</h1>
<h5>Outside District</h5>
<p class="text-muted">Potential geocoding errors</p>
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div v-if="user == null">
<p>loading</p>
</div>
<div v-else>
<MapMultipoint
id="map"
:markers="[]"
:organization-id="user.organization.id"
:tegola="user.urls.tegola"
:xmin="user?.organization?.serviceArea?.min.x ?? 0"
:ymin="user?.organization?.serviceArea?.min.y ?? 0"
:xmax="user?.organization?.serviceArea?.max.x ?? 0"
:ymax="user?.organization?.serviceArea?.max.y ?? 0"
></MapMultipoint>
</div>
</div>
<div class="card mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0">Data Preview</h5>
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
id="showIssuesOnly"
v-model="showIssuesOnly"
@change="handleShowIssuesOnly"
/>
<label class="form-check-label" for="showIssuesOnly">
Show issues only
</label>
</div>
</div>
<div class="card-body">
<div
v-for="error in upload?.errors"
:key="error.message"
class="alert alert-danger"
role="alert"
>
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>Error:</strong> {{ error.message }}
</div>
<div
v-if="upload?.status === 'uploaded' || upload?.status === 'parsing'"
class="alert alert-info"
role="alert"
>
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>Working:</strong> File is still processing... refresh this
page in a bit to see updates.
</div>
<template v-else>
<div
v-if="!upload?.pools || upload.pools.length === 0"
class="alert alert-warning"
role="alert"
>
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>Warning:</strong> No pools could be understood from your file.
</div>
<div v-else class="table-responsive">
<table class="table table-hover table-striped">
<thead class="table-light">
<tr class="header">
<th></th>
<th>Number</th>
<th>Street</th>
<th>City</th>
<th>Post</th>
<th>Status</th>
<th>Condition</th>
<th>Tags</th>
</tr>
</thead>
<tbody>
<tr
v-for="(pool, index) in upload.pools"
:key="index"
:class="{ 'has-error': pool.errors && pool.errors.length > 0 }"
:style="getRowStyle(pool)"
>
<td>
<i
v-if="pool.errors && pool.errors.length > 0"
class="bi bi-info-circle-fill text-primary ms-1"
data-bs-toggle="tooltip"
data-bs-placement="top"
:title="pool.errors.map(e => e.message).join(', ')"
></i>
</td>
<td>{{ pool.address.number }}</td>
<td>{{ pool.address.street }}</td>
<td>{{ pool.address.locality }}</td>
<td>{{ pool.address.postalCode }}</td>
<td>
<span class="badge status" :class="pool.status">
{{ titleCase(pool.status) }}
</span>
</td>
<td>
<span class="badge condition" :class="pool.condition">
{{ titleCase(pool.condition) }}
</span>
</td>
<td>{{ pool.tags?.length || 0 }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
</div>
<div class="d-flex justify-content-between mt-4 mb-5">
<button
type="button"
class="btn btn-outline-danger"
@click="handleDiscard"
:disabled="isSubmitting"
>
Discard
</button>
<button
class="btn btn-primary"
id="confirmUploadBtn"
@click="handleConfirm"
:disabled="isSubmitting"
>
<i class="bi bi-check2 me-1"></i> Confirm and Submit Data
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import MapMultipoint from "@/components/MapMultipoint.vue";
import { useUserStore } from "@/store/user";
interface Address {
number: string;
street: string;
locality: string;
postalCode: string;
}
interface ErrorMessage {
message: string;
}
interface Pool {
address: Address;
status: string;
condition: string;
tags?: string[];
errors?: ErrorMessage[];
}
interface Upload {
name: string;
status: string;
countExisting: number;
countNew: number;
countOutside: number;
errors?: ErrorMessage[];
pools?: Pool[];
}
interface ServiceArea {
min: { x: number; y: number };
max: { x: number; y: number };
}
interface Props {
id: int;
}
const props = defineProps<Props>()
const router = useRouter();
const showIssuesOnly = ref(false);
const isSubmitting = ref(false);
const user = useUserStore();
const getUploadStatusIcon = (status?: string): string => {
const icons: Record<string, string> = {
uploaded: 'bi-cloud-upload',
parsing: 'bi-hourglass-split',
parsed: 'bi-check-circle',
error: 'bi-x-circle',
};
return icons[status || ''] || 'bi-question-circle';
};
const getUploadStatusDisplay = (status?: string): string => {
const displays: Record<string, string> = {
uploaded: 'Uploaded',
parsing: 'Parsing',
parsed: 'Parsed',
error: 'Error',
};
return displays[status || ''] || 'Unknown';
};
const titleCase = (str?: string): string => {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
};
const upload = computed(() => {
if (upload.all == null) {
return null;
}
return upload.byID(props.id);
});
const getRowStyle = (pool: Pool) => {
if (showIssuesOnly.value) {
const hasError = pool.errors && pool.errors.length > 0;
return { display: hasError ? 'table-row' : 'none' };
}
return { display: 'table-row' };
};
const handleShowIssuesOnly = () => {
// The reactive display is handled by getRowStyle
};
const initializeMap = () => {
if (!map) return;
map.addEventListener('load', () => {
map.addSource('tegola-nidus', {
type: 'vector',
tiles: [
`${props.tegolaUrl}maps/nidus/{z}/{x}/{y}?csv_file=${props.id}&id=${user.organization.id}`,
],
});
map.addLayer({
id: 'pool',
source: 'tegola-nidus',
'source-layer': 'fileupload-pool',
type: 'circle',
paint: {
'circle-color': '#91b979',
'circle-radius': 7,
'circle-stroke-width': 2,
'circle-stroke-color': '#7aab5f',
},
});
});
};
const fetchUploadData = async () => {
try {
const response = await fetch(`/api/upload/${props.id}`);
if (!response.ok) throw new Error('Failed to fetch upload data');
upload.value = await response.json();
} catch (error) {
console.error('Error fetching upload data:', error);
}
};
const handleDiscard = async () => {
if (!confirm('Are you sure you want to discard this upload?')) return;
isSubmitting.value = true;
try {
const response = await fetch(`/api/upload/${props.id}/discard`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) throw new Error('Failed to discard upload');
// Navigate to uploads list or appropriate page
router.push('/uploads');
} catch (error) {
console.error('Error discarding upload:', error);
alert('Failed to discard upload. Please try again.');
} finally {
isSubmitting.value = false;
}
};
const handleConfirm = async () => {
isSubmitting.value = true;
try {
const response = await fetch(`/api/upload/${props.id}/commit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) throw new Error('Failed to confirm upload');
// Navigate to success page or appropriate page
router.push('/uploads/success');
} catch (error) {
console.error('Error confirming upload:', error);
alert('Failed to confirm upload. Please try again.');
} finally {
isSubmitting.value = false;
}
};
onMounted(() => {
initializeMap();
// If upload data wasn't provided via props, fetch it
if (!upload.value) {
fetchUploadData();
}
// Initialize Bootstrap tooltips
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
// @ts-ignore - Bootstrap types
[...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
});
</script>

View file

@ -53,6 +53,10 @@ export default defineConfig({
target: "http://127.0.0.1:9002",
changeOrigin: false,
},
"/configuration/upload/pool/flyover": {
target: "http://127.0.0.1:9002",
changeOrigin: false,
},
"/signin": {
target: "http://localhost:9002",
changeOrigin: false,