Get to where we can display something on pool review
This commit is contained in:
parent
33399b5e2a
commit
9921618c12
14 changed files with 919 additions and 44 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
a.card-link {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
66
ts/components/ReviewPoolColumnAction.vue
Normal file
66
ts/components/ReviewPoolColumnAction.vue
Normal 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>
|
||||||
151
ts/components/ReviewPoolColumnDetail.vue
Normal file
151
ts/components/ReviewPoolColumnDetail.vue
Normal 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>
|
||||||
55
ts/components/ReviewPoolColumnList.vue
Normal file
55
ts/components/ReviewPoolColumnList.vue
Normal 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>
|
||||||
25
ts/format.ts
25
ts/format.ts
|
|
@ -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}`;
|
||||||
|
|
|
||||||
11
ts/router.ts
11
ts/router.ts
|
|
@ -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
88
ts/store/review-task.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -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
486
ts/view/review/Pool.vue
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue