nidus-sync/html/template/sync/review/pool.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 = {
taskId: this.selectedTask.id,
action: 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-task", {
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> Mark Reviewed
</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 }}