Get to where we can display something on pool review

This commit is contained in:
Eli Ribble 2026-03-28 09:14:09 -07:00
parent 33399b5e2a
commit 9921618c12
No known key found for this signature in database
14 changed files with 919 additions and 44 deletions

View file

@ -16,22 +16,25 @@ import (
"github.com/stephenafamo/scan" "github.com/stephenafamo/scan"
) )
type reviewTaskPool struct { type reviewTask struct {
Address types.Address `json:"address"` Address types.Address `json:"address"`
Condition string `json:"condition"` Created time.Time `json:"created"`
Created time.Time `json:"created"` Creator platform.User `json:"creator"`
Creator platform.User `json:"creator"` ID int32 `json:"id"`
ID int32 `json:"id"` Location types.Location `json:"location"`
Location types.Location `json:"location"` Pool reviewTaskPool `json:"pool"`
Reviewed *time.Time `json:"addressed"` Reviewed *time.Time `json:"addressed"`
Reviewer *platform.User `json:"addressor"` Reviewer *platform.User `json:"addressor"`
} }
type contentListReviewTaskPool struct { type reviewTaskPool struct {
Tasks []reviewTaskPool `json:"tasks"` Condition string `json:"condition"`
Total int32 `json:"total"` }
type contentListReviewTask struct {
Tasks []reviewTask `json:"tasks"`
Total int32 `json:"total"`
} }
func listReviewTaskPool(ctx context.Context, r *http.Request, user platform.User, query queryParams) (*contentListReviewTaskPool, *nhttp.ErrorWithStatus) { func listReviewTask(ctx context.Context, r *http.Request, user platform.User, query queryParams) (*contentListReviewTask, *nhttp.ErrorWithStatus) {
limit := 20 limit := 20
if query.Limit != nil { if query.Limit != nil {
limit = *query.Limit limit = *query.Limit
@ -114,23 +117,25 @@ func listReviewTaskPool(ctx context.Context, r *http.Request, user platform.User
if err != nil { if err != nil {
return nil, nhttp.NewError("users by id: %w", err) return nil, nhttp.NewError("users by id: %w", err)
} }
tasks := make([]reviewTaskPool, len(rows)) tasks := make([]reviewTask, len(rows))
for i, row := range rows { for i, row := range rows {
tasks[i] = reviewTaskPool{ tasks[i] = reviewTask{
Address: row.Address, Address: row.Address,
Condition: row.Condition, Created: row.Created,
Created: row.Created, Creator: *users_by_id[row.CreatorID],
Creator: *users_by_id[row.CreatorID], ID: row.ID,
ID: row.ID,
Location: types.Location{ Location: types.Location{
Latitude: row.Latitude, Latitude: row.Latitude,
Longitude: row.Longitude, Longitude: row.Longitude,
}, },
Pool: reviewTaskPool{
Condition: row.Condition,
},
Reviewed: row.Reviewed, Reviewed: row.Reviewed,
Reviewer: userOrNil(users_by_id, row.ReviewerID), Reviewer: userOrNil(users_by_id, row.ReviewerID),
} }
} }
return &contentListReviewTaskPool{ return &contentListReviewTask{
Tasks: tasks, Tasks: tasks,
Total: row_total.Total, Total: row_total.Total,
}, nil }, nil

View file

@ -30,7 +30,7 @@ func AddRoutes(r chi.Router) {
r.Method("POST", "/publicreport/signal", authenticatedHandlerJSONPost(postPublicreportSignal)) r.Method("POST", "/publicreport/signal", authenticatedHandlerJSONPost(postPublicreportSignal))
r.Method("POST", "/publicreport/message", authenticatedHandlerJSONPost(postPublicreportMessage)) r.Method("POST", "/publicreport/message", authenticatedHandlerJSONPost(postPublicreportMessage))
r.Method("POST", "/review/pool", authenticatedHandlerJSONPost(postReviewPool)) r.Method("POST", "/review/pool", authenticatedHandlerJSONPost(postReviewPool))
r.Method("GET", "/review-task/pool", authenticatedHandlerJSON(listReviewTaskPool)) r.Method("GET", "/review-task", authenticatedHandlerJSON(listReviewTask))
r.Method("GET", "/service-request", auth.NewEnsureAuth(apiServiceRequest)) r.Method("GET", "/service-request", auth.NewEnsureAuth(apiServiceRequest))
r.Method("GET", "/signal", authenticatedHandlerJSON(listSignal)) r.Method("GET", "/signal", authenticatedHandlerJSON(listSignal))
r.Method("POST", "/sudo/email", authenticatedHandlerJSONPost(postSudoEmail)) r.Method("POST", "/sudo/email", authenticatedHandlerJSONPost(postSudoEmail))

View file

@ -13,6 +13,7 @@ import (
type contentURLAPI struct { type contentURLAPI struct {
Communication string `json:"communication"` Communication string `json:"communication"`
PublicreportMessage string `json:"publicreport_message"` PublicreportMessage string `json:"publicreport_message"`
ReviewTask string `json:"review_task"`
Signal string `json:"signal"` Signal string `json:"signal"`
Upload string `json:"upload"` Upload string `json:"upload"`
} }
@ -44,6 +45,7 @@ func getUserSelf(ctx context.Context, r *http.Request, user platform.User, query
API: contentURLAPI{ API: contentURLAPI{
Communication: urls.API.Communication, Communication: urls.API.Communication,
PublicreportMessage: urls.API.Publicreport.Message, PublicreportMessage: urls.API.Publicreport.Message,
ReviewTask: config.MakeURLNidus("/api/review-task"),
Signal: config.MakeURLNidus("/api/signal"), Signal: config.MakeURLNidus("/api/signal"),
Upload: config.MakeURLNidus("/api/upload"), Upload: config.MakeURLNidus("/api/upload"),
}, },

View file

@ -1,3 +0,0 @@
a.card-link {
text-decoration: none;
}

View file

@ -315,6 +315,7 @@ import { computed } from "vue";
import MapMultipoint from "@/components/MapMultipoint.vue"; import MapMultipoint from "@/components/MapMultipoint.vue";
import PublicreportCard from "@/components/PublicreportCard.vue"; import PublicreportCard from "@/components/PublicreportCard.vue";
import TimeRelative from "@/components/TimeRelative.vue"; import TimeRelative from "@/components/TimeRelative.vue";
import { formatAddress } from "@/format";
interface Emits { interface Emits {
(e: "viewImage", index: int): void; (e: "viewImage", index: int): void;
@ -324,12 +325,6 @@ interface Props {
} }
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>();
const props = defineProps<Props>(); const props = defineProps<Props>();
function formatAddress(a) {
if (a.number === "" && a.street === "") {
return "no address provided";
}
return `${a.number} ${a.street}, ${a.locality}`;
}
function openPhotoViewer(index) { function openPhotoViewer(index) {
emit("viewImage", index); emit("viewImage", index);
} }

View file

@ -0,0 +1,66 @@
<template>
<h5 class="mb-4">Actions</h5>
<button
class="btn btn-success action-btn"
@click="markReviewed"
:disabled="!selectedTask || submitting"
>
<span v-if="!submitting">
<i class="bi bi-check-circle"></i> Complete Review
</span>
<span v-else>
<span class="spinner-border spinner-border-sm" role="status"></span>
Submitting...
</span>
</button>
<div v-if="selectedTask" class="card mt-3">
<div v-if="changes.updated.length > 0" class="card-body">
<h6 class="card-title">
<i class="bi bi-pencil-square text-warning"></i> Updates
</h6>
<ul class="mb-0">
<li v-for="item in changes.updated" :key="item">{{ item }}</li>
</ul>
</div>
<div v-if="changes.unchanged.length > 0" class="card-body">
<h6 class="card-title">
<i class="bi bi-dash-circle text-muted"></i> Not changed
</h6>
<ul class="mb-0">
<li v-for="item in changes.unchanged" :key="item">{{ item }}</li>
</ul>
</div>
</div>
<button
class="btn btn-danger action-btn"
@click="discardEntry"
:disabled="!selectedTask || submitting"
>
<i class="bi bi-trash"></i> Discard Entry
</button>
<!-- Keyboard Shortcuts Help -->
<div class="card mt-3">
<div class="card-body">
<h6 class="card-title"><i class="bi bi-keyboard"></i> Tips</h6>
<small class="text-muted">
Fields with a yellow border have been modified from their original
values.
</small>
</div>
</div>
</template>
<script setup lang="ts">
import MapMultipoint from "@/components/MapMultipoint.vue";
import MapProxiedArcgisTile from "@/components/MapProxiedArcgisTile.vue";
import ReviewTask from "@/types";
interface Props {
selectedTask?: ReviewTask;
submitting: boolean;
}
const props = defineProps<Props>();
</script>

View file

@ -0,0 +1,151 @@
<template>
<!-- No Selection State -->
<div
v-if="!selectedTask"
class="h-100 d-flex align-items-center justify-content-center text-muted"
>
<div class="text-center">
<i class="bi bi-cursor-fill" style="font-size: 48px"></i>
<p class="mt-2">Select an entry from the list to review</p>
</div>
</div>
<!-- Selected Task Details -->
<div v-else>
<div class="mb-4">
<h4 class="mb-3">Entry #{{ selectedTask.id }} Details</h4>
<form @submit.prevent>
<div class="row mb-3">
<label class="col-sm-3 col-form-label fw-bold">Address:</label>
<div class="col-sm-9">
<input
type="text"
class="form-control"
:value="formatAddress(selectedTask.address)"
readonly
/>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label fw-bold">Longitude:</label>
<div class="col-sm-9">
<input
type="text"
class="form-control"
v-model="form.longitude"
:class="{
'border-warning': form.longitude !== originalValues.longitude,
}"
/>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label fw-bold">Latitude:</label>
<div class="col-sm-9">
<input
type="text"
class="form-control"
v-model="form.latitude"
:class="{
'border-warning': form.latitude !== originalValues.latitude,
}"
/>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label fw-bold">Pool Condition:</label>
<div class="col-sm-9">
<select
class="form-select"
v-model="form.condition"
:class="{
'border-warning': form.condition !== originalValues.condition,
}"
>
<option value="">-- Select --</option>
<option value="blue">Blue</option>
<option value="dry">Dry</option>
<option value="false pool">False Pool</option>
<option value="unknown">Unknown</option>
<option value="green">Green</option>
<option value="murky">Murky</option>
</select>
</div>
</div>
<div v-if="form.ownerContact" class="row mb-3">
<label class="col-sm-3 col-form-label fw-bold">Owner Contact:</label>
<div class="col-sm-9">
<input
type="text"
class="form-control"
v-model="form.ownerContact"
:class="{
'border-warning':
form.ownerContact !== originalValues.ownerContact,
}"
/>
</div>
</div>
<div v-if="form.residentContact" class="row mb-4">
<label class="col-sm-3 col-form-label fw-bold">
Resident Contact:
</label>
<div class="col-sm-9">
<input
type="text"
class="form-control"
v-model="form.residentContact"
:class="{
'border-warning':
form.residentContact !== originalValues.residentContact,
}"
/>
</div>
</div>
</form>
</div>
<!-- Map Components -->
<div class="map-container">
<MapMultipoint
ref="mapMultipoint"
id="map"
:organization-id="organizationId"
:tegola="tegolaUrl"
:xmin="serviceArea.xmin"
:ymin="serviceArea.ymin"
:xmax="serviceArea.xmax"
:ymax="serviceArea.ymax"
></MapMultipoint>
</div>
<div class="map-container">
<MapProxiedArcgisTile
ref="mapTile"
class="map"
:organization-id="organizationId"
:tegola="tegolaUrl"
:tiles-url="tilesUrl"
:latitude="selectedTask.location.latitude"
:longitude="selectedTask.location.longitude"
@map-click="updatePoolLocation"
></MapProxiedArcgisTile>
</div>
</div>
</template>
<script setup lang="ts">
import MapMultipoint from "@/components/MapMultipoint.vue";
import MapProxiedArcgisTile from "@/components/MapProxiedArcgisTile.vue";
import ReviewTask from "@/types";
interface Props {
selectedTask?: ReviewTask;
}
const props = defineProps<Props>();
</script>

View file

@ -0,0 +1,55 @@
<template>
<!-- Error Alert -->
<div v-if="error" class="mt-3 alert alert-danger alert-dismissible">
{{ error }}
<button type="button" class="btn-close" @click="error = null"></button>
</div>
<div class="p-3 border-bottom bg-primary text-white">
<h5 class="mb-0"><i class="bi bi-list-ul"></i> Review Queue</h5>
<small>{{ total }} entries pending</small>
</div>
<!-- Loading State -->
<div v-if="tasks == null" class="p-4 text-center">
<span class="spinner-border" role="status"></span>
<p class="mt-2">Loading tasks...</p>
</div>
<!-- Empty State -->
<div v-else-if="tasks.length === 0" class="p-4 text-center text-muted">
<i class="bi bi-check-circle" style="font-size: 48px"></i>
<p class="mt-2">No entries to review!</p>
</div>
<!-- Task List -->
<div
v-for="task in tasks"
:key="task.id"
class="entry-item"
:class="{ active: selectedTaskID === task.id }"
@click="selectTask(task)"
>
<div class="d-flex justify-content-between align-items-start">
<div>
<i class="bi bi-droplet"></i>
<strong>Pool {{ task.id }}</strong>
</div>
<small class="text-muted">{{ task.condition }}</small>
</div>
<small class="text-muted d-block mt-1">
{{ formatAddress(task.address) }}
</small>
</div>
</template>
<script setup lang="ts">
import { formatAddress } from "@/format";
interface Props {
error: string | null;
selectedTaskID: int | null;
tasks: ReviewTask[];
total: int;
}
const props = defineProps<Props>();
</script>

View file

@ -1,5 +1,30 @@
import { Address } from "./types"; import { Address } from "./types";
export function formatAddress(address?: Address): string {
if (!address) {
return "undefined";
}
if (address.number === "" && address.street === "") {
return "no address provided";
}
return `${address.number} ${address.street}, ${address.locality}`;
}
export function formatRelativeTime(dateString: string): string {
if (!dateString) return "";
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins} min ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
}
export function shortAddress(a: Address): string { export function shortAddress(a: Address): string {
if (!a) return ""; if (!a) return "";
return `${a.number} ${a.street}, ${a.locality}`; return `${a.number} ${a.street}, ${a.locality}`;

View file

@ -20,7 +20,8 @@ import NotFound from "./view/NotFound.vue";
import OAuthRefreshArcgis from "./view/OAuthRefreshArcgis.vue"; import OAuthRefreshArcgis from "./view/OAuthRefreshArcgis.vue";
import Operations from "./view/Operations.vue"; import Operations from "./view/Operations.vue";
import Planning from "./view/Planning.vue"; import Planning from "./view/Planning.vue";
import Review from "./view/Review.vue"; import ReviewPool from "./view/review/Pool.vue";
import ReviewRoot from "./view/review/Root.vue";
import Signin from "./view/Signin.vue"; import Signin from "./view/Signin.vue";
import Sudo from "./view/Sudo.vue"; import Sudo from "./view/Sudo.vue";
import apiClient from "@/client"; import apiClient from "@/client";
@ -138,7 +139,13 @@ const routes: RouteRecordRaw[] = [
{ {
path: "/_/review", path: "/_/review",
name: "Review", name: "Review",
component: Review, component: ReviewRoot,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/review/pool",
name: "Pool Review",
component: ReviewPool,
meta: { requiresAuth: true, showSidebar: true }, meta: { requiresAuth: true, showSidebar: true },
}, },
{ {

88
ts/store/review-task.ts Normal file
View file

@ -0,0 +1,88 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { ReviewTask } from "../types";
import { SSEManager } from "../SSEManager";
import { useUserStore } from "./user";
export const useReviewTaskStore = defineStore("review-task", () => {
// State
const _byID = ref<Map<int, ReviewTask>>(new Map());
const all = ref<ReviewTask[] | null>(null);
const loading = ref(false);
const error = ref(null);
// Subscription
SSEManager.subscribe("*", (e) => {
if (e.resource.startsWith("review-task")) {
fetchAll();
}
});
// Actions
function byID(id: int) {
return _byID.value.get(id);
}
async function fetchAll(): Promise<void> {
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.review_task}?${params}`,
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
all.value = data.tasks;
for (const t of data.tasks) {
_byID.value.set(t.id, t);
}
} catch (err) {
error.value = err instanceof Error ? err.message : "Unknown error";
console.error("Error loading tasks:", err);
throw err;
} finally {
loading.value = false;
}
}
async function fetchOne(id: int) {
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 response = await fetch(`${userStore.urls.api.review_task}/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
_byID.value.set(data.id, data);
return data;
} catch (err) {
console.error("Error loading tasks:", err);
throw err;
}
}
return {
// State
all,
// Actions
byID,
fetchAll,
fetchOne,
};
});

View file

@ -58,17 +58,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, nextTick } from "vue"; import { computed, onMounted, ref } from "vue";
import maplibregl from "maplibre-gl"; import maplibregl from "maplibre-gl";
import { useCommunicationStore } from "../store/communication"; import { useCommunicationStore } from "@/store/communication";
import { useUserStore } from "../store/user"; import { useUserStore } from "@/store/user";
import CommunicationColumnAction from "../components/CommunicationColumnAction.vue"; import CommunicationColumnAction from "@/components/CommunicationColumnAction.vue";
import CommunicationColumnDetail from "../components/CommunicationColumnDetail.vue"; import CommunicationColumnDetail from "@/components/CommunicationColumnDetail.vue";
import CommunicationColumnList from "../components/CommunicationColumnList.vue"; import CommunicationColumnList from "@/components/CommunicationColumnList.vue";
import PhotoViewerModal from "../components/PhotoViewerModal.vue"; import PhotoViewerModal from "@/components/PhotoViewerModal.vue";
import ThreeColumn from "../components/layout/ThreeColumn.vue"; import ThreeColumn from "@/components/layout/ThreeColumn.vue";
import ToastNotification from "../components/ToastNotification.vue"; import ToastNotification from "@/components/ToastNotification.vue";
const communication = useCommunicationStore(); const communication = useCommunicationStore();
const user = useUserStore(); const user = useUserStore();
@ -328,8 +328,6 @@ onMounted(async () => {
} }
// Setup map layer after next tick to ensure map is mounted // Setup map layer after next tick to ensure map is mounted
await nextTick();
/* /*
if (mapRef.value) { if (mapRef.value) {
const mapEl = mapRef.value.$el || mapRef.value; const mapEl = mapRef.value.$el || mapRef.value;

486
ts/view/review/Pool.vue Normal file
View file

@ -0,0 +1,486 @@
<style scoped>
body {
background-color: #f5f5f5;
}
.left-panel {
background-color: white;
height: 100vh;
overflow-y: auto;
border-right: 1px solid #dee2e6;
}
.middle-panel {
background-color: white;
height: 100vh;
overflow-y: auto;
padding: 20px;
}
.right-panel {
background-color: white;
height: 100vh;
overflow-y: auto;
border-left: 1px solid #dee2e6;
padding: 20px;
}
.entry-item {
padding: 15px;
border-bottom: 1px solid #e9ecef;
cursor: pointer;
transition: background-color 0.2s;
}
.entry-item:hover {
background-color: #f8f9fa;
}
.entry-item.active {
background-color: #e7f3ff;
border-left: 4px solid #0d6efd;
}
.placeholder-box {
background-color: #e9ecef;
border: 2px dashed #adb5bd;
display: flex;
align-items: center;
justify-content: center;
color: #6c757d;
font-size: 18px;
margin-bottom: 20px;
}
.map-placeholder {
height: 300px;
}
.image-placeholder {
height: 400px;
}
.action-btn {
width: 100%;
margin-bottom: 10px;
padding: 12px;
font-size: 16px;
}
.status-badge {
font-size: 11px;
}
.map-container {
margin-bottom: 20px;
}
</style>
<template>
<ThreeColumn>
<template #left>
<ReviewPoolColumnList
v-if="reviewTask.all"
:error="error"
:selectedTaskID="selectedTaskID"
:tasks="reviewTask.all"
:total="totalPending"
/>
<div v-else>
<p>Loading</p>
</div>
</template>
<template #center>
<ReviewPoolColumnDetail :selectedTask="selectedTask" />
</template>
<template #right>
<ReviewPoolColumnAction :submitting="submitting" />
</template>
</ThreeColumn>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useReviewTaskStore } from "@/store/review-task";
import { useUserStore } from "@/store/user";
import maplibregl from "maplibre-gl";
import ThreeColumn from "@/components/layout/ThreeColumn.vue";
import ReviewTask from "@/types";
import ReviewPoolColumnAction from "@/components/ReviewPoolColumnAction.vue";
import ReviewPoolColumnDetail from "@/components/ReviewPoolColumnDetail.vue";
import ReviewPoolColumnList from "@/components/ReviewPoolColumnList.vue";
// TypeScript Interfaces
interface Address {
number: string;
street: string;
locality: string;
}
interface Location {
latitude: number;
longitude: number;
}
interface Task {
id: number;
location: Location;
condition: string;
ownerContact?: string;
residentContact?: string;
poolShape?: string;
address?: Address;
}
interface FormData {
latitude: number;
longitude: number;
condition: string;
ownerContact: string;
residentContact: string;
poolShape: string;
}
interface FieldConfig {
key: keyof FormData;
label: string;
}
interface Changes {
updated: string[];
unchanged: string[];
}
interface MapClickEvent {
detail: {
map: any;
lat: number;
lng: number;
};
}
// Props (you can pass these from parent component or environment)
interface Props {
organizationId?: string;
tegolaUrl?: string;
tilesUrl?: string;
serviceArea?: {
xmin: number;
ymin: number;
xmax: number;
ymax: number;
};
}
const props = withDefaults(defineProps<Props>(), {
organizationId: "",
tegolaUrl: "",
tilesUrl: "",
serviceArea: () => ({
xmin: 0,
ymin: 0,
xmax: 0,
ymax: 0,
}),
});
// State
const totalPending = ref<number>(0);
const selectedTaskID = ref<int | null>(null);
const originalValues = ref<Partial<FormData>>({});
const loading = ref<boolean>(true);
const submitting = ref<boolean>(false);
const error = ref<string | null>(null);
const reviewTask = useReviewTaskStore();
const user = useUserStore();
// Refs for map components
const mapMultipoint = ref<any>(null);
const mapTile = ref<any>(null);
// Computed: track which fields have changed
const changes = computed<Changes>(() => {
if (!selectedTask.value) return { updated: [], unchanged: [] };
const updated: string[] = [];
const unchanged: string[] = [];
const fields: FieldConfig[] = [
{ key: "latitude", label: "Latitude" },
{ key: "longitude", label: "Longitude" },
{ key: "condition", label: "Pool condition" },
{ key: "ownerContact", label: "Owner contact" },
{ key: "residentContact", label: "Resident contact" },
{ key: "poolShape", label: "Pool shape" },
];
fields.forEach((field) => {
if (form[field.key] !== originalValues.value[field.key]) {
updated.push(field.label);
} else {
unchanged.push(field.label);
}
});
return { updated, unchanged };
});
const selectedTask = computed<ReviewTask | null>(() => {
if (selectedTaskID.value == null) {
return null;
}
return reviewTask.byID(selectedTaskID.value);
});
async function fetchTasks() {
await reviewTask.fetchAll();
}
// Helper Functions
// Task Selection
function selectTask(task: Task): void {
console.log("Selected task", task);
selectedTask.value = task;
// Populate form with task values
form.latitude = task.location.latitude;
form.longitude = task.location.longitude;
form.condition = task.condition || "";
form.ownerContact = task.ownerContact || "";
form.residentContact = task.residentContact || "";
form.poolShape = task.poolShape || "";
// Store original values for change tracking
originalValues.value = { ...form };
// Update map
updateMap(task);
}
// Map Update
function updateMap(task: Task): void {
console.log("Updating map for task:", task.id);
const map = mapMultipoint.value;
if (!map) return;
const loc = task.location;
const markers = [
new maplibregl.Marker({
color: "#FF0000",
draggable: false,
}).setLngLat([loc.longitude, loc.latitude]),
];
map.SetMarkers(markers);
const bounds = new maplibregl.LngLatBounds(
new maplibregl.LngLat(loc.longitude - 0.005, loc.latitude - 0.005),
new maplibregl.LngLat(loc.longitude + 0.005, loc.latitude + 0.005),
);
map.FitBounds(bounds, {
padding: 50,
});
}
// Map Click Handler
function updatePoolLocation(event: MapClickEvent): void {
console.log("map click", selectedTask.value?.id, event.detail);
const map = event.detail.map;
const loc = {
latitude: event.detail.lat,
longitude: event.detail.lng,
};
map.SetMarkers([
new maplibregl.Marker({
color: "#FF0000",
draggable: false,
}).setLngLat([event.detail.lng, event.detail.lat]),
]);
form.latitude = event.detail.lat;
form.longitude = event.detail.lng;
}
// Submit Review
async function submitReview(action: "committed" | "discarded"): Promise<void> {
if (!selectedTask.value || submitting.value) return;
submitting.value = true;
error.value = null;
try {
const payload: any = {
task_id: selectedTask.value.id,
status: action,
updates: {},
};
// Include changed fields in the payload
if (action === "committed") {
(Object.keys(form) as Array<keyof FormData>).forEach((key) => {
if (form[key] !== originalValues.value[key]) {
payload.updates[key] = form[key];
}
});
}
const response = await fetch("/api/review/pool", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error("Failed to submit review");
}
// Remove task from list
const taskIndex = reviewTask.all.value.findIndex(
(t) => t.id === selectedTask.value!.id,
);
if (taskIndex > -1) {
reviewTask.all.value.splice(taskIndex, 1);
totalPending.value = Math.max(0, totalPending.value - 1);
}
// Select next task or clear selection
if (reviewTask.all.length > 0) {
const nextIndex = Math.min(taskIndex, reviewTask.all.length - 1);
selectTask(reviewTask.all[nextIndex]);
} else {
selectedTask.value = null;
form.condition = "";
form.ownerContact = "";
form.residentContact = "";
form.poolShape = "";
form.latitude = 0;
form.longitude = 0;
originalValues.value = {};
}
// Update list of tasks
await fetchTasks();
} catch (err) {
error.value = err instanceof Error ? err.message : "Unknown error";
console.error("Error submitting review:", err);
} finally {
submitting.value = false;
}
}
// Action Handlers
function markReviewed(): void {
submitReview("committed");
}
function discardEntry(): void {
submitReview("discarded");
}
// Initialize Maps
function initializeMaps(): void {
const mapElement = mapMultipoint.value;
const mapTileElement = mapTile.value;
if (mapElement) {
mapElement.addEventListener("load", () => {
mapElement.addLayer({
id: "parcel",
minzoom: 14,
paint: {
"line-color": "#0f0",
},
source: "tegola",
"source-layer": "parcel",
type: "line",
});
mapElement.addLayer({
id: "pools",
paint: {
"circle-color": "#0D6EfD",
"circle-radius": 7,
"circle-stroke-width": 2,
"circle-stroke-color": "#024AB6",
},
source: "tegola",
"source-layer": "feature-pool",
type: "circle",
});
// Create a popup
const popup = new maplibregl.Popup({
closeButton: false,
closeOnClick: false,
});
let currentFeatureCoordinates: string | undefined;
mapElement.addEventListener("mousemove", "pools", (e: any) => {
const featureCoordinates =
e.features[0].geometry.coordinates.toString();
if (currentFeatureCoordinates !== featureCoordinates) {
currentFeatureCoordinates = featureCoordinates;
mapElement.getCanvas().style.cursor = "pointer";
const coordinates = e.features[0].geometry.coordinates.slice();
const condition = e.features[0].properties.condition;
while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
}
popup
.setLngLat(coordinates)
.setHTML(condition)
.addTo(mapElement._map);
}
});
mapElement.addEventListener("mouseleave", "pools", () => {
currentFeatureCoordinates = undefined;
mapElement.getCanvas().style.cursor = "";
popup.remove();
});
});
}
if (mapTileElement) {
mapTileElement.addEventListener("load", () => {
mapTileElement.addLayer({
id: "parcel",
minzoom: 14,
paint: {
"line-color": "#0f0",
},
source: "tegola",
"source-layer": "parcel",
type: "line",
});
mapTileElement.addLayer({
id: "pools",
paint: {
"circle-color": "#0D6EfD",
"circle-radius": 7,
"circle-stroke-width": 2,
"circle-stroke-color": "#024AB6",
},
source: "tegola",
"source-layer": "feature-pool",
type: "circle",
});
});
}
}
// Lifecycle
onMounted(async () => {
initializeMaps();
await fetchTasks();
});
</script>