Add beginnings of status page
This commit is contained in:
parent
2c0bfb9904
commit
37ce3183ca
6 changed files with 413 additions and 10 deletions
|
|
@ -2,8 +2,11 @@ package resource
|
|||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
|
||||
"github.com/Gleipnir-Technology/nidus-sync/platform"
|
||||
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
|
||||
"net/http"
|
||||
//"github.com/rs/zerolog/log"
|
||||
"github.com/gorilla/mux"
|
||||
|
|
@ -14,9 +17,15 @@ type publicreportR struct {
|
|||
}
|
||||
|
||||
type publicreport struct {
|
||||
ID string `json:"id"`
|
||||
District string `json:"district"`
|
||||
URI string `json:"uri"`
|
||||
Address string `json:"address"`
|
||||
Created time.Time `json:"created"`
|
||||
District string `json:"district"`
|
||||
ID string `json:"id"`
|
||||
ImageCount int `json:"image_count"`
|
||||
Location types.Location `json:"location"`
|
||||
Status string `json:"status"`
|
||||
Type string `json:"type"`
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
func Publicreport(r *router) *publicreportR {
|
||||
|
|
@ -39,8 +48,23 @@ func (res *publicreportR) ByID(ctx context.Context, r *http.Request, query Query
|
|||
if err != nil {
|
||||
return nil, nhttp.NewError("district uri: %w", err)
|
||||
}
|
||||
uri, err := res.router.IDStrToURI("publicreport.ByIDGet", report.PublicID)
|
||||
if err != nil {
|
||||
return nil, nhttp.NewError("uri: %w", err)
|
||||
}
|
||||
location := types.Location{
|
||||
Latitude: report.LocationLatitude.GetOr(0.0),
|
||||
Longitude: report.LocationLongitude.GetOr(0.0),
|
||||
}
|
||||
return &publicreport{
|
||||
District: district_uri,
|
||||
ID: report.PublicID,
|
||||
District: district_uri,
|
||||
ID: report.PublicID,
|
||||
Address: report.AddressRaw,
|
||||
Created: report.Created,
|
||||
ImageCount: len(report.R.Images),
|
||||
Location: location,
|
||||
Status: report.Status.String(),
|
||||
Type: report.ReportType.String(),
|
||||
URI: uri,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
178
ts/components/MapLocatorDisplay.vue
Normal file
178
ts/components/MapLocatorDisplay.vue
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
<style scoped>
|
||||
@import url("https://unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css");
|
||||
.map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
/* Ensure map fills container on all devices */
|
||||
:deep(.maplibregl-map) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.maplibregl-canvas) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<!-- Map container -->
|
||||
<div ref="mapContainer" class="map-container"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import maplibregl from "maplibre-gl";
|
||||
import {
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
type Ref,
|
||||
shallowRef,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
} from "vue";
|
||||
import { boundsMarkers, boundsDefault } from "@/map-utils";
|
||||
import type { Marker } from "@/types";
|
||||
import type { Location } from "@/type/api";
|
||||
import type { Camera, MoveEndEventInternal } from "@/type/map";
|
||||
|
||||
// Emits interface
|
||||
interface Emits {}
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
markers?: Marker[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
markers: () => [],
|
||||
});
|
||||
|
||||
// Refs
|
||||
const map: Ref<maplibregl.Map | null> = shallowRef(null);
|
||||
const mapContainer = ref<HTMLDivElement | null>(null);
|
||||
const mapMarkers: Ref<Map<string, maplibregl.Marker>> = shallowRef<
|
||||
Map<string, maplibregl.Marker>
|
||||
>(new Map());
|
||||
|
||||
// Initialize map
|
||||
function initializeMap() {
|
||||
if (!mapContainer.value) return;
|
||||
|
||||
let bounds = boundsDefault();
|
||||
if (props.markers.length > 0) {
|
||||
bounds = boundsMarkers(props.markers);
|
||||
}
|
||||
const _map = new maplibregl.Map({
|
||||
bounds: bounds,
|
||||
container: mapContainer.value,
|
||||
style: "https://tiles.stadiamaps.com/styles/alidade_smooth.json",
|
||||
// Disable interactions by default
|
||||
doubleClickZoom: false,
|
||||
dragPan: false,
|
||||
scrollZoom: false,
|
||||
touchZoomRotate: false,
|
||||
});
|
||||
_map.addControl(new maplibregl.NavigationControl(), "top-left");
|
||||
map.value = _map;
|
||||
}
|
||||
|
||||
// Update markers on the map
|
||||
const updateMarkers = () => {
|
||||
if (!map.value) return;
|
||||
|
||||
// Remove markers that no longer exist
|
||||
const currentMarkerIds = new Set(props.markers.map((m) => m.id));
|
||||
for (const [id, marker] of mapMarkers.value) {
|
||||
if (!currentMarkerIds.has(id)) {
|
||||
marker.remove();
|
||||
mapMarkers.value.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Add or update markers
|
||||
props.markers.forEach((markerData) => {
|
||||
let marker = mapMarkers.value.get(markerData.id);
|
||||
|
||||
if (marker) {
|
||||
// Update existing marker
|
||||
marker.setLngLat([
|
||||
markerData.location.longitude,
|
||||
markerData.location.latitude,
|
||||
]);
|
||||
marker.setDraggable(markerData.draggable ?? false);
|
||||
} else {
|
||||
marker = new maplibregl.Marker({
|
||||
draggable: markerData.draggable ?? false,
|
||||
})
|
||||
.setLngLat([
|
||||
markerData.location.longitude,
|
||||
markerData.location.latitude,
|
||||
])
|
||||
.addTo(map.value!);
|
||||
|
||||
mapMarkers.value.set(markerData.id, marker);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Frame all markers in view
|
||||
const frameMarkers = () => {
|
||||
if (!map.value || props.markers.length === 0) return;
|
||||
|
||||
if (props.markers.length === 1) {
|
||||
// Single marker: pan to it
|
||||
map.value.panTo(
|
||||
{
|
||||
lat: props.markers[0].location.latitude,
|
||||
lng: props.markers[0].location.longitude,
|
||||
},
|
||||
{ duration: 1000 },
|
||||
{ isInternalUpdate: true },
|
||||
);
|
||||
} else {
|
||||
// Multiple markers: fit bounds
|
||||
const bounds = new maplibregl.LngLatBounds();
|
||||
props.markers.forEach((marker) => {
|
||||
bounds.extend([marker.location.longitude, marker.location.latitude]);
|
||||
});
|
||||
map.value.fitBounds(
|
||||
bounds,
|
||||
{ padding: 10, duration: 1000 },
|
||||
{ isInternalUpdate: true },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for markers changes
|
||||
watch(
|
||||
() => props.markers,
|
||||
() => {
|
||||
updateMarkers();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
initializeMap();
|
||||
updateMarkers();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// Remove all markers
|
||||
mapMarkers.value.forEach((marker) => marker.remove());
|
||||
mapMarkers.value.clear();
|
||||
|
||||
// Remove map
|
||||
if (map.value) {
|
||||
map.value.remove();
|
||||
map.value = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
@ -16,6 +16,7 @@ import NuisanceBase from "@/rmo/view/Nuisance.vue";
|
|||
import NuisanceDistrict from "@/rmo/view/district/Nuisance.vue";
|
||||
import ReportSubmitted from "@/rmo/view/ReportSubmitted.vue";
|
||||
import StatusBase from "@/rmo/view/Status.vue";
|
||||
import StatusByID from "@/rmo/view/StatusByID.vue";
|
||||
import StatusDistrict from "@/rmo/view/district/Status.vue";
|
||||
import Water from "@/rmo/view/Water.vue";
|
||||
import WaterDistrict from "@/rmo/view/district/Water.vue";
|
||||
|
|
@ -118,6 +119,12 @@ const routes: RouteRecordRaw[] = [
|
|||
name: "StatusBase",
|
||||
component: StatusBase,
|
||||
},
|
||||
{
|
||||
component: StatusByID,
|
||||
name: "StatusbyID",
|
||||
path: "/status/:id",
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: "/water",
|
||||
name: "Water",
|
||||
|
|
|
|||
161
ts/rmo/view/StatusByID.vue
Normal file
161
ts/rmo/view/StatusByID.vue
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
<style scoped>
|
||||
.timeline {
|
||||
border-left: 3px solid #dee2e6;
|
||||
padding-left: 20px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.timeline-item:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -29px;
|
||||
top: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background-color: #0d6efd;
|
||||
}
|
||||
|
||||
.timeline-date {
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="container my-4" v-if="report">
|
||||
<!-- Report ID and Status Section -->
|
||||
<div class="card mb-4">
|
||||
<div
|
||||
class="card-header bg-primary text-white d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<h5 class="mb-0">Report {{ id }}</h5>
|
||||
<span class="badge bg-warning text-dark status-badge">
|
||||
{{ report.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<strong><i class="bi bi-tag me-2"></i>Type:</strong>
|
||||
<span>{{ report.type }}</span>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<strong><i class="bi bi-calendar me-2"></i>Created:</strong>
|
||||
<span>{{ formatTimeRelative(report.created) }}</span>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3" v-if="district">
|
||||
<strong><i class="bi bi-crosshair me-2"></i>District:</strong>
|
||||
<span>
|
||||
{{ district.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<strong><i class="bi bi-pin-map me-2"></i>Location:</strong>
|
||||
<span>{{ report.address }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<strong><i class="bi bi-images me-2"></i>Images:</strong>
|
||||
<span>
|
||||
{{
|
||||
report.image_count > 0 ? report.image_count : "None provided"
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Section -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-pin-map-fill me-2"></i>Location Map
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="map-container">
|
||||
<MapLocatorDisplay id="map" :markers="markers"></MapLocatorDisplay>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container my-4" v-else>
|
||||
<p>loading...</p>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { computedAsync } from "@vueuse/core";
|
||||
import MapLocatorDisplay from "@/components/MapLocatorDisplay.vue";
|
||||
import { useStoreDistrict } from "@/rmo/store/district";
|
||||
import { useStorePublicreport } from "@/store/publicreport";
|
||||
import type { Marker } from "@/types";
|
||||
import type { District, Publicreport } from "@/type/api";
|
||||
import { formatTimeRelative } from "@/format";
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const storeDistrict = useStoreDistrict();
|
||||
const storePublicreport = useStorePublicreport();
|
||||
// Computed
|
||||
const report = computedAsync(async (): Promise<Publicreport | undefined> => {
|
||||
return await storePublicreport.byID(props.id);
|
||||
});
|
||||
const district = computedAsync(async (): Promise<District | undefined> => {
|
||||
if (!(report.value && report.value.district)) {
|
||||
return undefined;
|
||||
}
|
||||
return await storeDistrict.byURI(report.value.district);
|
||||
});
|
||||
const markers = computed((): Marker[] => {
|
||||
if (!(report.value && report.value.location)) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: report.value.id,
|
||||
location: report.value.location,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
// Load map scripts if needed
|
||||
loadMapScripts();
|
||||
});
|
||||
|
||||
const loadMapScripts = () => {
|
||||
// Load MapLibre GL if not already loaded
|
||||
if (!document.querySelector('script[src*="maplibre-gl"]')) {
|
||||
const script1 = document.createElement("script");
|
||||
script1.src = "//unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.js";
|
||||
document.head.appendChild(script1);
|
||||
}
|
||||
|
||||
// Load Mapbox GL if not already loaded
|
||||
if (!document.querySelector('script[src*="mapbox-gl"]')) {
|
||||
const script2 = document.createElement("script");
|
||||
script2.src =
|
||||
"https://api.mapbox.com/mapbox-gl-js/v3.17.0-beta.1/mapbox-gl.js";
|
||||
document.head.appendChild(script2);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import type { Publicreport } from "@/type/api";
|
||||
import { Publicreport, type PublicreportDTO } from "@/type/api";
|
||||
|
||||
export const useStorePublicreport = defineStore("publicreport", () => {
|
||||
// State
|
||||
|
|
@ -23,9 +23,10 @@ export const useStorePublicreport = defineStore("publicreport", () => {
|
|||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const body = await response.json();
|
||||
_byID.value.set(id, body);
|
||||
return body;
|
||||
const body: PublicreportDTO = await response.json();
|
||||
const report = Publicreport.fromJSON(body);
|
||||
_byID.value.set(id, report);
|
||||
return report;
|
||||
} catch (err) {
|
||||
console.error("Error loading users:", err);
|
||||
throw err;
|
||||
|
|
|
|||
|
|
@ -32,8 +32,40 @@ export interface Geocode {
|
|||
cell: number;
|
||||
location: Location;
|
||||
}
|
||||
export interface Publicreport {
|
||||
export interface PublicreportDTO {
|
||||
address: string;
|
||||
created: string;
|
||||
district: string;
|
||||
id: string;
|
||||
image_count: number;
|
||||
location: Location;
|
||||
status: string;
|
||||
type: string;
|
||||
uri: string;
|
||||
}
|
||||
export class Publicreport {
|
||||
constructor(
|
||||
public address: string,
|
||||
public created: Date,
|
||||
public district: string,
|
||||
public id: string,
|
||||
public image_count: number,
|
||||
public location: Location,
|
||||
public status: string,
|
||||
public type: string,
|
||||
public uri: string,
|
||||
) {}
|
||||
static fromJSON(json: PublicreportDTO): Publicreport {
|
||||
return new Publicreport(
|
||||
json.address,
|
||||
new Date(json.created),
|
||||
json.district,
|
||||
json.id,
|
||||
json.image_count,
|
||||
json.location,
|
||||
json.status,
|
||||
json.type,
|
||||
json.uri,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue