nidus-sync/ts/view/Communication.vue
Eli Ribble b4ae9e5a95
Move communication workbench to use resource store
Because it's getting better all the time, including by adding the
ability to get new resources when they get created over SSE.
2026-05-20 23:49:59 +00:00

305 lines
8.5 KiB
Vue

<style scoped></style>
<template>
<ThreeColumn>
<template #header>
<div class="col">
<h3 class="mb-1">Communication Workbench</h3>
<div class="text-muted small">
Communications from various sources come in at the left, are
investigated in the center, and labeled as valuable signal or invalid
on the right.
</div>
</div>
</template>
<template #left>
<CommunicationColumnList
:all="visibleCommunications"
@deselect="handleDeselect"
:loading="storeResource.communication.loadingAll()"
:selectedID="selectedId"
@select="handleSelect"
/>
</template>
<template #center>
<CommunicationColumnDetail
:loading="
storePublicReport.loading ||
storeResource.communication.loadingURI(selectedCommunication?.uri)
"
:mapBounds="mapBounds || undefined"
:mapMarkers="mapMarkers"
:selectedCommunication="selectedCommunication"
:selectedReport="selectedReport"
@viewImage="openImageViewer"
/>
</template>
<template #right>
<CommunicationColumnAction
:isLoading="
storePublicReport.loading ||
storeResource.communication.loadingURI(selectedCommunication?.uri)
"
@markInvalid="markInvalid"
@markPendingResponse="markPendingResponse"
@markPossibleIssue="markPossibleIssue"
@markPossibleResolved="markPossibleResolved"
@sendMessage="sendMessage"
:selectedCommunication="selectedCommunication"
:selectedReport="selectedReport"
/>
</template>
</ThreeColumn>
<ImageViewerModal
@close="showImageModal = false"
@imageNext="imageNext()"
@imagePrevious="imagePrevious()"
:images="currentImages"
:currentImageIndex="currentImageIndex"
:show="showImageModal"
/>
<ToastNotification
:message="toastMessage"
:show="toastShow"
:title="toastTitle"
/>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { computedAsync } from "@vueuse/core";
import maplibregl from "maplibre-gl";
import { apiClient } from "@/client";
import CommunicationColumnAction from "@/components/CommunicationColumnAction.vue";
import CommunicationColumnDetail from "@/components/CommunicationColumnDetail.vue";
import CommunicationColumnList from "@/components/CommunicationColumnList.vue";
import ImageViewerModal from "@/components/ImageViewerModal.vue";
import ThreeColumn from "@/components/layout/ThreeColumn.vue";
import ToastNotification from "@/components/ToastNotification.vue";
import { useQueryParam } from "@/composable/use-query-param";
import { SSEManager } from "@/SSEManager";
import { useStoreResource } from "@/store/resource";
import { useSessionStore } from "@/store/session";
import type { Marker } from "@/types";
import { Bounds, type Communication, PublicReport } from "@/type/api";
import type { LngLatBounds } from "@/map/Map.vue";
import { boundsForServiceArea, boundsWithPadding } from "@/map/util";
import { useStorePublicReport } from "@/store/publicreport";
const session = useSessionStore();
// Refs
const currentImageIndex = ref<number>(0);
const paramCommunication = useQueryParam("communication");
const selectedId = ref<string | undefined>(undefined);
const showImageModal = ref(false);
const storePublicReport = useStorePublicReport();
const storeResource = useStoreResource();
const toastMessage = ref("");
const toastShow = ref(false);
const toastTitle = ref("");
const currentImage = computed(() => {
const comm = selectedCommunication.value;
return selectedReport.value?.images[currentImageIndex.value] ?? null;
});
const currentImages = computed(() => {
const comm = selectedCommunication.value;
if (comm == null) {
return [];
}
return selectedReport.value?.images ?? [];
});
const mapBounds = computed<LngLatBounds | null>((): LngLatBounds | null => {
let bounds = new Bounds();
const loc = selectedReport.value?.location;
console.log("updating for loc", loc);
if (loc && loc.latitude != 0 && loc.longitude != 0) {
bounds.addLocation(loc);
}
const address_loc = selectedReport.value?.address.location;
if (address_loc && address_loc.latitude != 0 && address_loc.longitude != 0) {
bounds.addLocation(address_loc);
}
for (const [i, image] of (selectedReport.value?.images ?? []).entries()) {
if (
image.location != null &&
image.location.latitude != 0 &&
image.location.longitude != 0
) {
bounds.addLocation(image.location);
}
}
if (bounds.isEmpty()) {
return boundsForServiceArea();
}
return boundsWithPadding(bounds.min, bounds.max, 0.01);
});
const mapMarkers = computed<Marker[]>((): Marker[] => {
const loc = selectedReport.value?.location;
let markers: Marker[] = [];
if (loc && loc.latitude != 0 && loc.longitude != 0) {
markers.push({
color: "#0000FF",
draggable: false,
id: "reporter",
location: loc,
});
}
const address_loc = selectedReport.value?.address.location;
if (address_loc && address_loc.latitude != 0 && address_loc.longitude != 0) {
markers.push({
color: "#FF0000",
draggable: false,
id: "address",
location: address_loc,
});
}
for (const [i, image] of (selectedReport.value?.images ?? []).entries()) {
if (
image.location != null &&
image.location.latitude != 0 &&
image.location.longitude != 0
) {
markers.push({
color: "#00FF00",
draggable: false,
id: `image-${i}`,
location: image.location,
});
}
}
return markers;
});
const selectedCommunication = computedAsync(
async (): Promise<Communication | undefined> => {
if (selectedId.value == undefined) {
return undefined;
}
const all = await storeResource.communication.byAll();
return all.find((c: Communication) => c.id == selectedId.value);
},
);
const selectedReport = computedAsync(
async (): Promise<PublicReport | undefined> => {
if (
!(
selectedCommunication.value &&
selectedCommunication.value.type != "publicreport"
)
)
return;
return await storePublicReport.byURI(selectedCommunication.value.source);
},
);
const visibleCommunications = computedAsync(
async (): Promise<Communication[]> => {
const all = await storeResource.communication.byAll();
return all.filter((c: Communication) => {
return c.status == "new" || c.status == "opened";
});
},
);
const handleDeselect = (id: string) => {
selectedId.value = undefined;
};
const handleSelect = (id: string) => {
selectedId.value = id;
paramCommunication.setValue(id);
};
function imageNext() {
currentImageIndex.value = Math.min(
currentImages.value.length - 1,
currentImageIndex.value + 1,
);
}
function imagePrevious() {
currentImageIndex.value = Math.max(0, currentImageIndex.value - 1);
}
function openImageViewer(index: number) {
currentImageIndex.value = index;
showImageModal.value = true;
}
async function markInvalid() {
markReport("Invalid", "invalid");
}
async function markPendingResponse() {
markReport("Pending Response", "pending-response");
}
async function markPossibleIssue() {
markReport("Possible Issue", "possible-issue");
}
async function markPossibleResolved() {
markReport("Possibly Resolved", "possible-resolved");
}
async function markReport(title: string, status: string) {
if (selectedCommunication.value == null) {
return;
}
const url = `${selectedCommunication.value.uri}/mark/${status}`;
const result = apiClient.JSONPost(url, {});
showNotification(
`Report Marked ${title}`,
`Report #${selectedCommunication.value.id} has been updated`,
);
await storeResource.communication.fetchAll();
}
async function sendMessage(message: string) {
if (!message.trim()) return;
if (selectedCommunication.value == null) return;
if (selectedReport.value == null) return;
if (session.urls == null) return;
console.log("Sending message reporter:", message);
const payload = {
message: message,
reportID: selectedCommunication.value.id,
};
const response = await fetch(session.urls?.api.publicreport_message, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
showNotification(
"Message Sent",
`Message successfully sent to ${selectedReport.value.reporter.name}`,
);
}
function showNotification(title: string, message: string) {
toastTitle.value = title;
toastMessage.value = message;
toastShow.value = true;
setTimeout(() => {
toastShow.value = false;
}, 3000);
}
// Lifecycle hooks
onMounted(async () => {
await storeResource.communication.fetchAll();
});
watch(
paramCommunication.value,
(communication_id) => {
if (communication_id) {
handleSelect(communication_id);
}
},
{ immediate: true },
);
</script>