614 lines
16 KiB
HTML
614 lines
16 KiB
HTML
{{ template "sync/layout/authenticated.html" . }}
|
|
|
|
{{ define "title" }}Review - Pools{{ end }}
|
|
{{ define "extraheader" }}
|
|
<script
|
|
type="text/javascript"
|
|
src="//unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.js"
|
|
></script>
|
|
<script
|
|
defer
|
|
src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
|
|
></script>
|
|
<script src="/static/js/map-proxied-arcgis-tile.js"></script>
|
|
<script src="/static/js/map-multipoint.js"></script>
|
|
<script src="/static/js/time-relative.js"></script>
|
|
<script>
|
|
function formatAddress(a) {
|
|
if (a == undefined) {
|
|
return "undefined";
|
|
}
|
|
if (a.number == "" && a.street == "") {
|
|
return "no address provided";
|
|
}
|
|
return a.number + " " + a.street + ", " + a.locality;
|
|
}
|
|
function reviewApp() {
|
|
return {
|
|
// State
|
|
tasks: [],
|
|
totalPending: 0,
|
|
selectedTask: null,
|
|
originalValues: {},
|
|
loading: true,
|
|
submitting: false,
|
|
error: null,
|
|
|
|
// Form fields for the selected task
|
|
form: {
|
|
poolCondition: "",
|
|
poolShape: "",
|
|
poolLocation: {
|
|
latitude: 0,
|
|
longitude: 0,
|
|
},
|
|
ownerContact: "",
|
|
residentContact: "",
|
|
},
|
|
|
|
// Computed: track which fields have changed
|
|
get changes() {
|
|
if (!this.selectedTask) return { updated: [], unchanged: [] };
|
|
|
|
const updated = [];
|
|
const unchanged = [];
|
|
|
|
const fields = [
|
|
{ key: "latitude", label: "Latitude" },
|
|
{ key: "longitude", label: "Longitude" },
|
|
{ key: "poolCondition", label: "Pool condition" },
|
|
{ key: "ownerContact", label: "Owner contact" },
|
|
{ key: "residentContact", label: "Resident contact" },
|
|
{ key: "poolShape", label: "Pool shape" },
|
|
];
|
|
|
|
fields.forEach((field) => {
|
|
if (this.form[field.key] !== this.originalValues[field.key]) {
|
|
updated.push(field.label);
|
|
} else {
|
|
unchanged.push(field.label);
|
|
}
|
|
});
|
|
|
|
return { updated, unchanged };
|
|
},
|
|
|
|
// Initialize
|
|
async init() {
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const map = document.querySelector("map-multipoint");
|
|
map.on("load", () => {
|
|
map.addLayer({
|
|
id: "parcel",
|
|
minzoom: 14,
|
|
paint: {
|
|
"line-color": "#0f0",
|
|
},
|
|
source: "tegola",
|
|
"source-layer": "parcel",
|
|
type: "line",
|
|
});
|
|
});
|
|
});
|
|
await this.fetchTasks();
|
|
},
|
|
|
|
// Fetch tasks from API
|
|
async fetchTasks() {
|
|
this.loading = true;
|
|
this.error = null;
|
|
|
|
try {
|
|
const response = await fetch("/api/review-task/pool");
|
|
if (!response.ok) {
|
|
throw new Error("Failed to fetch tasks");
|
|
}
|
|
|
|
const data = await response.json();
|
|
this.tasks = data.tasks || [];
|
|
this.totalPending = data.totalPending || this.tasks.length;
|
|
|
|
// Auto-select first task if available
|
|
if (this.tasks.length > 0 && !this.selectedTask) {
|
|
this.selectTask(this.tasks[0]);
|
|
}
|
|
} catch (err) {
|
|
this.error = err.message;
|
|
console.error("Error fetching tasks:", err);
|
|
} finally {
|
|
this.loading = false;
|
|
console.log("set loading", this.loading);
|
|
}
|
|
},
|
|
|
|
// Select a task
|
|
selectTask(task) {
|
|
console.log("Selected task", task);
|
|
this.selectedTask = task;
|
|
|
|
// Populate form with task values
|
|
this.form = {
|
|
latitude: task.location.latitude,
|
|
longitude: task.location.longitude,
|
|
poolCondition: task.condition || "",
|
|
ownerContact: task.ownerContact || "",
|
|
residentContact: task.residentContact || "",
|
|
poolShape: task.poolShape || "",
|
|
};
|
|
|
|
// Store original values for change tracking
|
|
this.originalValues = { ...this.form };
|
|
|
|
// Update map and aerial image
|
|
this.updateMap(task);
|
|
this.updateAerialImage(task);
|
|
},
|
|
|
|
// Stub: Update map display
|
|
updateMap(task) {
|
|
console.log("Updating map for task:", task.id);
|
|
const map = document.querySelector("map-multipoint");
|
|
const loc = this.selectedTask.location;
|
|
let 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.01, loc.latitude - 0.01),
|
|
new maplibregl.LngLat(loc.longitude + 0.01, loc.latitude + 0.01),
|
|
);
|
|
|
|
map.FitBounds(bounds, {
|
|
padding: 50,
|
|
});
|
|
},
|
|
|
|
// Stub: Update aerial image display
|
|
updateAerialImage(task) {
|
|
console.log("Updating aerial image for task:", task.id);
|
|
// TODO: Load aerial image
|
|
// Example implementation:
|
|
// const imageEl = document.getElementById('aerial-image');
|
|
// if (imageEl && task.aerialImageUrl) {
|
|
// imageEl.src = task.aerialImageUrl;
|
|
// }
|
|
},
|
|
|
|
// Format relative time
|
|
formatRelativeTime(dateString) {
|
|
if (!dateString) return "";
|
|
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffMs = now - date;
|
|
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`;
|
|
},
|
|
|
|
// Submit review action
|
|
async submitReview(action) {
|
|
if (!this.selectedTask || this.submitting) return;
|
|
|
|
this.submitting = true;
|
|
this.error = null;
|
|
|
|
try {
|
|
const payload = {
|
|
task_id: this.selectedTask.id,
|
|
status: action, // 'reviewed' or 'discarded'
|
|
updates: {},
|
|
};
|
|
|
|
// Include changed fields in the payload
|
|
if (action === "reviewed") {
|
|
Object.keys(this.form).forEach((key) => {
|
|
if (this.form[key] !== this.originalValues[key]) {
|
|
payload.updates[key] = this.form[key];
|
|
}
|
|
});
|
|
}
|
|
|
|
const response = await fetch(`/api/review/`, {
|
|
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 = this.tasks.findIndex(
|
|
(t) => t.id === this.selectedTask.id,
|
|
);
|
|
if (taskIndex > -1) {
|
|
this.tasks.splice(taskIndex, 1);
|
|
this.totalPending = Math.max(0, this.totalPending - 1);
|
|
}
|
|
|
|
// Select next task or clear selection
|
|
if (this.tasks.length > 0) {
|
|
const nextIndex = Math.min(taskIndex, this.tasks.length - 1);
|
|
this.selectTask(this.tasks[nextIndex]);
|
|
} else {
|
|
this.selectedTask = null;
|
|
this.form = {
|
|
poolCondition: "",
|
|
ownerContact: "",
|
|
residentContact: "",
|
|
poolShape: "",
|
|
};
|
|
this.originalValues = {};
|
|
}
|
|
} catch (err) {
|
|
this.error = err.message;
|
|
console.error("Error submitting review:", err);
|
|
} finally {
|
|
this.submitting = false;
|
|
}
|
|
},
|
|
|
|
// Mark as reviewed
|
|
markReviewed() {
|
|
this.submitReview("reviewed");
|
|
},
|
|
|
|
// Discard entry
|
|
discardEntry() {
|
|
if (confirm("Are you sure you want to discard this entry?")) {
|
|
this.submitReview("discarded");
|
|
}
|
|
},
|
|
updatePoolLocation(e, pool_id) {
|
|
console.log("map click", pool_id, e.detail);
|
|
const map = e.detail.map;
|
|
const loc = {
|
|
latitude: e.detail.lat,
|
|
longitude: e.detail.lng,
|
|
};
|
|
map.SetMarkers([
|
|
new maplibregl.Marker({
|
|
color: "#FF0000",
|
|
draggable: false,
|
|
}).setLngLat([e.detail.lng, e.detail.lat]),
|
|
]);
|
|
this.form.latitude = e.detail.lat;
|
|
this.form.longitude = e.detail.lng;
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
<style>
|
|
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;
|
|
}
|
|
</style>
|
|
{{ end }}
|
|
{{ define "content" }}
|
|
<div class="container-fluid" x-data="reviewApp()">
|
|
<div class="row">
|
|
<!-- Left Column - Entry List -->
|
|
<div class="col-md-3 p-0 left-panel">
|
|
<!-- Error Alert -->
|
|
<div x-show="error" class="mt-3">
|
|
<span x-text="error"></span>
|
|
<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><span x-text="totalPending"></span> entries pending</small>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div
|
|
x-show="!loading && 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 -->
|
|
<template x-for="task in tasks" :key="task.id">
|
|
<div
|
|
class="entry-item"
|
|
:class="{ 'active': selectedTask && selectedTask.id === task.id }"
|
|
@click="selectTask(task)"
|
|
>
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div>
|
|
<i class="bi bi-droplet"></i>
|
|
<strong>Pool <span x-text="task.id"></span></strong>
|
|
</div>
|
|
<small class="text-muted" x-text="task.condition"></small>
|
|
</div>
|
|
<small
|
|
class="text-muted d-block mt-1"
|
|
x-text="formatAddress(task.address)"
|
|
></small>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Middle Column - Details and Media -->
|
|
<div class="col-md-6 p-0 middle-panel">
|
|
<!-- No Selection State -->
|
|
<template x-show="selectedTask == null">
|
|
<div
|
|
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>
|
|
</template>
|
|
|
|
<!-- Selected Task Details -->
|
|
<div x-show="selectedTask">
|
|
<div class="mb-4">
|
|
<h4 class="mb-3">
|
|
Entry #<span x-text="selectedTask?.id"></span> 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"
|
|
x-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"
|
|
x-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"
|
|
x-model="form.poolCondition"
|
|
:class="{ 'border-warning': form.poolCondition !== originalValues.poolCondition }"
|
|
>
|
|
<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>
|
|
|
|
<template x-if="form.ownerContact != ''">
|
|
<div 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"
|
|
x-model="form.ownerContact"
|
|
:class="{ 'border-warning': form.ownerContact !== originalValues.ownerContact }"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="form.residentContact != ''">
|
|
<div 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"
|
|
x-model="form.residentContact"
|
|
:class="{ 'border-warning': form.residentContact !== originalValues.residentContact }"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Map Placeholder -->
|
|
<div class="map-container">
|
|
<map-multipoint
|
|
id="map"
|
|
organization-id="{{ .Organization.ID }}"
|
|
tegola="{{ .URL.Tegola }}"
|
|
xmin="{{ .Organization.ServiceAreaXmin.GetOr 0 }}"
|
|
ymin="{{ .Organization.ServiceAreaYmin.GetOr 0 }}"
|
|
xmax="{{ .Organization.ServiceAreaXmax.GetOr 0 }}"
|
|
ymax="{{ .Organization.ServiceAreaYmax.GetOr 0 }}"
|
|
></map-multipoint>
|
|
</div>
|
|
|
|
<!-- Aerial Image Placeholder -->
|
|
<div class="map-container">
|
|
<map-proxied-arcgis-tile
|
|
class="map"
|
|
arcgis-access-token="{{ .C.ArcgisAccessToken }}"
|
|
organization-id="{{ .Organization.ID }}"
|
|
tegola="{{ .URL.Tegola }}"
|
|
{{ .C.URLTiles }}
|
|
@map-click="updatePoolLocation($event, selectedTask.id)"
|
|
:latitude="selectedTask?.location.latitude ?? 0"
|
|
:longitude="selectedTask?.location.longitude ?? 0"
|
|
>
|
|
</map-proxied-arcgis-tile>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Column - Actions -->
|
|
<div class="col-md-3 p-0 right-panel">
|
|
<h5 class="mb-4">Actions</h5>
|
|
|
|
<button
|
|
class="btn btn-success action-btn"
|
|
@click="markReviewed()"
|
|
:disabled="!selectedTask || submitting"
|
|
>
|
|
<span x-show="!submitting">
|
|
<i class="bi bi-check-circle"></i> Complete Review
|
|
</span>
|
|
<span x-show="submitting">
|
|
<span class="spinner-border spinner-border-sm" role="status"></span>
|
|
Submitting...
|
|
</span>
|
|
</button>
|
|
|
|
<button
|
|
class="btn btn-danger action-btn"
|
|
@click="discardEntry()"
|
|
:disabled="!selectedTask || submitting"
|
|
>
|
|
<i class="bi bi-trash"></i> Discard Entry
|
|
</button>
|
|
|
|
<div class="card mt-3" x-show="selectedTask">
|
|
<div class="card-body" x-show="changes.updated.length > 0">
|
|
<h6 class="card-title">
|
|
<i class="bi bi-pencil-square text-warning"></i> Updates
|
|
</h6>
|
|
<ul class="mb-0">
|
|
<template x-for="item in changes.updated" :key="item">
|
|
<li x-text="item"></li>
|
|
</template>
|
|
</ul>
|
|
</div>
|
|
<div class="card-body" x-show="changes.unchanged.length > 0">
|
|
<h6 class="card-title">
|
|
<i class="bi bi-dash-circle text-muted"></i> Not changed
|
|
</h6>
|
|
<ul class="mb-0">
|
|
<template x-for="item in changes.unchanged" :key="item">
|
|
<li x-text="item"></li>
|
|
</template>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{{ end }}
|