Show nuisance report status

This commit is contained in:
Eli Ribble 2026-04-08 23:37:00 +00:00
parent 37ce3183ca
commit b2c24a0438
No known key found for this signature in database
10 changed files with 133 additions and 82 deletions

View file

@ -18,20 +18,15 @@ import (
"github.com/Gleipnir-Technology/nidus-sync/platform/email" "github.com/Gleipnir-Technology/nidus-sync/platform/email"
"github.com/Gleipnir-Technology/nidus-sync/platform/event" "github.com/Gleipnir-Technology/nidus-sync/platform/event"
"github.com/Gleipnir-Technology/nidus-sync/platform/geocode" "github.com/Gleipnir-Technology/nidus-sync/platform/geocode"
"github.com/Gleipnir-Technology/nidus-sync/platform/publicreport"
"github.com/Gleipnir-Technology/nidus-sync/platform/report" "github.com/Gleipnir-Technology/nidus-sync/platform/report"
"github.com/Gleipnir-Technology/nidus-sync/platform/text" "github.com/Gleipnir-Technology/nidus-sync/platform/text"
"github.com/Gleipnir-Technology/nidus-sync/platform/types" "github.com/Gleipnir-Technology/nidus-sync/platform/types"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
func PublicreportByID(ctx context.Context, report_id string) (*models.PublicreportReport, error) { func PublicreportByID(ctx context.Context, report_id string) (*types.Report, error) {
report, err := models.PublicreportReports.Query( return publicreport.Report(ctx, report_id)
models.SelectWhere.PublicreportReports.PublicID.EQ(report_id),
).One(ctx, db.PGInstance.BobDB)
if err != nil {
return nil, err
}
return report, nil
} }
func PublicreportInvalid(ctx context.Context, user User, report_id string) error { func PublicreportInvalid(ctx context.Context, user User, report_id string) error {
report, err := reportFromID(ctx, user, report_id) report, err := reportFromID(ctx, user, report_id)

View file

@ -29,7 +29,7 @@ WHERE i.id IN (1, 2, 3, 4)
GROUP BY i.id; GROUP BY i.id;
*/ */
// Get all the images that belong to the list of report IDs // Get all the images that belong to the list of report IDs
func loadImagesForReport(ctx context.Context, org_id int32, report_ids []int32) (results map[int32][]types.Image, err error) { func loadImagesForReport(ctx context.Context, report_ids []int32) (results map[int32][]types.Image, err error) {
rows, err := bob.All(ctx, db.PGInstance.BobDB, psql.Select( rows, err := bob.All(ctx, db.PGInstance.BobDB, psql.Select(
sm.Columns( sm.Columns(
"i.storage_uuid AS uuid", "i.storage_uuid AS uuid",

View file

@ -18,13 +18,14 @@ import (
) )
func ReportsForOrganization(ctx context.Context, org_id int32) ([]*types.Report, error) { func ReportsForOrganization(ctx context.Context, org_id int32) ([]*types.Report, error) {
query := reportQuery(org_id) query := reportQuery()
query.Apply( query.Apply(
sm.Where(psql.Quote("publicreport", "report", "organization_id").EQ(psql.Arg(org_id))),
sm.Where(psql.Quote("publicreport", "report", "reviewed").IsNull()), sm.Where(psql.Quote("publicreport", "report", "reviewed").IsNull()),
) )
return reportQueryToRows(ctx, org_id, query) return reportQueryToRows(ctx, query)
} }
func reportQueryToRows(ctx context.Context, org_id int32, query bob.BaseQuery[*dialect.SelectQuery]) ([]*types.Report, error) { func reportQueryToRows(ctx context.Context, query bob.BaseQuery[*dialect.SelectQuery]) ([]*types.Report, error) {
rows, err := bob.All(ctx, db.PGInstance.BobDB, query, scan.StructMapper[types.Report]()) rows, err := bob.All(ctx, db.PGInstance.BobDB, query, scan.StructMapper[types.Report]())
if err != nil { if err != nil {
@ -34,7 +35,7 @@ func reportQueryToRows(ctx context.Context, org_id int32, query bob.BaseQuery[*d
for i, row := range rows { for i, row := range rows {
report_ids[i] = row.ID report_ids[i] = row.ID
} }
images_by_id, err := loadImagesForReport(ctx, org_id, report_ids) images_by_id, err := loadImagesForReport(ctx, report_ids)
if err != nil { if err != nil {
return nil, fmt.Errorf("images for report: %w", err) return nil, fmt.Errorf("images for report: %w", err)
} }
@ -69,12 +70,27 @@ func reportQueryToRows(ctx context.Context, org_id int32, query bob.BaseQuery[*d
} }
return results, nil return results, nil
} }
func Reports(ctx context.Context, org_id int32, ids []int32) ([]*types.Report, error) { func Report(ctx context.Context, public_id string) (*types.Report, error) {
query := reportQuery(org_id) query := reportQuery()
query.Apply( query.Apply(
sm.Where(psql.Quote("publicreport", "report", "public_id").EQ(psql.Arg(public_id))),
)
reports, err := reportQueryToRows(ctx, query)
if err != nil {
return nil, fmt.Errorf("query to rows: %w", err)
}
if len(reports) != 1 {
return nil, fmt.Errorf("reports returned: %d", len(reports))
}
return reports[0], nil
}
func Reports(ctx context.Context, org_id int32, ids []int32) ([]*types.Report, error) {
query := reportQuery()
query.Apply(
sm.Where(psql.Quote("publicreport", "report", "organization_id").EQ(psql.Arg(org_id))),
sm.Where(psql.Quote("publicreport", "report", "id").EQ(psql.Any(ids))), sm.Where(psql.Quote("publicreport", "report", "id").EQ(psql.Any(ids))),
) )
return reportQueryToRows(ctx, org_id, query) return reportQueryToRows(ctx, query)
} }
func ReportsForOrganizationCount(ctx context.Context, org_id int32) (uint, error) { func ReportsForOrganizationCount(ctx context.Context, org_id int32) (uint, error) {
type _Row struct { type _Row struct {
@ -92,10 +108,11 @@ func ReportsForOrganizationCount(ctx context.Context, org_id int32) (uint, error
} }
return row.Count, nil return row.Count, nil
} }
func reportQuery(org_id int32) bob.BaseQuery[*dialect.SelectQuery] { func reportQuery() bob.BaseQuery[*dialect.SelectQuery] {
return psql.Select( return psql.Select(
sm.Columns( sm.Columns(
"address_country AS \"address.country\"", "address_country AS \"address.country\"",
"address_gid AS \"address.gid\"",
"address_locality AS \"address.locality\"", "address_locality AS \"address.locality\"",
"address_number AS \"address.number\"", "address_number AS \"address.number\"",
"address_postal_code AS \"address.postal_code\"", "address_postal_code AS \"address.postal_code\"",
@ -106,6 +123,7 @@ func reportQuery(org_id int32) bob.BaseQuery[*dialect.SelectQuery] {
"id", "id",
"COALESCE(ST_Y(location::geometry::geometry(point, 4326)), 0.0) AS \"location.latitude\"", "COALESCE(ST_Y(location::geometry::geometry(point, 4326)), 0.0) AS \"location.latitude\"",
"COALESCE(ST_X(location::geometry::geometry(point, 4326)), 0.0) AS \"location.longitude\"", "COALESCE(ST_X(location::geometry::geometry(point, 4326)), 0.0) AS \"location.longitude\"",
"organization_id",
"public_id", "public_id",
"report_type", "report_type",
"reporter_email AS \"reporter.email\"", "reporter_email AS \"reporter.email\"",
@ -114,6 +132,5 @@ func reportQuery(org_id int32) bob.BaseQuery[*dialect.SelectQuery] {
"status", "status",
), ),
sm.From("publicreport.report"), sm.From("publicreport.report"),
sm.Where(psql.Quote("publicreport", "report", "organization_id").EQ(psql.Arg(org_id))),
) )
} }

View file

@ -13,9 +13,12 @@ type Report struct {
Location *Location `db:"location" json:"location"` Location *Location `db:"location" json:"location"`
Log []LogEntry `db:"-" json:"log"` Log []LogEntry `db:"-" json:"log"`
Nuisance *Nuisance `db:"nuisance" json:"nuisance"` Nuisance *Nuisance `db:"nuisance" json:"nuisance"`
DistrictID *int32 `db:"organization_id" json:"-"`
District *string `db:"-" json:"district"`
PublicID string `db:"public_id" json:"public_id"` PublicID string `db:"public_id" json:"public_id"`
Reporter Contact `db:"reporter" json:"reporter"` Reporter Contact `db:"reporter" json:"reporter"`
Status string `db:"status" json:"status"` Status string `db:"status" json:"status"`
Type string `db:"report_type" json:"type"` Type string `db:"report_type" json:"type"`
URI string `db:"-" json:"uri"`
Water *Water `db:"water" json:"water"` Water *Water `db:"water" json:"water"`
} }

View file

@ -20,6 +20,7 @@ type district struct {
Name string `json:"name"` Name string `json:"name"`
PhoneOffice string `json:"phone_office"` PhoneOffice string `json:"phone_office"`
Slug string `json:"slug"` Slug string `json:"slug"`
URI string `json:"uri"`
URLLogo string `json:"url_logo"` URLLogo string `json:"url_logo"`
URLWebsite string `json:"url_website"` URLWebsite string `json:"url_website"`
} }
@ -75,10 +76,15 @@ func newDistrict(r *router, org *platform.Organization) (*district, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("logo url: %w", err) return nil, fmt.Errorf("logo url: %w", err)
} }
uri, err := r.IDToURI("district.ByIDGet", int(org.ID))
if err != nil {
return nil, nhttp.NewError("district uri: %w", err)
}
return &district{ return &district{
Name: org.Name(), Name: org.Name(),
PhoneOffice: org.PhoneOffice(), PhoneOffice: org.PhoneOffice(),
Slug: slug, Slug: slug,
URI: uri,
URLLogo: logo, URLLogo: logo,
URLWebsite: org.Website(), URLWebsite: org.Website(),
}, nil }, nil

View file

@ -2,7 +2,6 @@ package resource
import ( import (
"context" "context"
"time"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http" nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
"github.com/Gleipnir-Technology/nidus-sync/platform" "github.com/Gleipnir-Technology/nidus-sync/platform"
@ -16,25 +15,13 @@ type publicreportR struct {
router *router router *router
} }
type publicreport struct {
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 { func Publicreport(r *router) *publicreportR {
return &publicreportR{ return &publicreportR{
router: r, router: r,
} }
} }
func (res *publicreportR) ByID(ctx context.Context, r *http.Request, query QueryParams) (*publicreport, *nhttp.ErrorWithStatus) { func (res *publicreportR) ByID(ctx context.Context, r *http.Request, query QueryParams) (*types.Report, *nhttp.ErrorWithStatus) {
vars := mux.Vars(r) vars := mux.Vars(r)
public_id := vars["id"] public_id := vars["id"]
if public_id == "" { if public_id == "" {
@ -44,27 +31,18 @@ func (res *publicreportR) ByID(ctx context.Context, r *http.Request, query Query
if err != nil { if err != nil {
return nil, nhttp.NewError("get report: %w", err) return nil, nhttp.NewError("get report: %w", err)
} }
district_uri, err := res.router.IDToURI("district.ByIDGet", int(report.OrganizationID)) var district_uri string
if err != nil { if report.DistrictID != nil {
return nil, nhttp.NewError("district uri: %w", err) district_uri, err = res.router.IDToURI("district.ByIDGet", int(*report.DistrictID))
if err != nil {
return nil, nhttp.NewError("district uri: %w", err)
}
} }
uri, err := res.router.IDStrToURI("publicreport.ByIDGet", report.PublicID) uri, err := res.router.IDStrToURI("publicreport.ByIDGet", report.PublicID)
if err != nil { if err != nil {
return nil, nhttp.NewError("uri: %w", err) return nil, nhttp.NewError("uri: %w", err)
} }
location := types.Location{ report.District = &district_uri
Latitude: report.LocationLatitude.GetOr(0.0), report.URI = uri
Longitude: report.LocationLongitude.GetOr(0.0), return report, nil
}
return &publicreport{
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
} }

View file

@ -103,10 +103,10 @@ const updateMarkers = () => {
markerData.location.longitude, markerData.location.longitude,
markerData.location.latitude, markerData.location.latitude,
]); ]);
marker.setDraggable(markerData.draggable ?? false); marker.setDraggable(false);
} else { } else {
marker = new maplibregl.Marker({ marker = new maplibregl.Marker({
draggable: markerData.draggable ?? false, draggable: false,
}) })
.setLngLat([ .setLngLat([
markerData.location.longitude, markerData.location.longitude,
@ -117,6 +117,7 @@ const updateMarkers = () => {
mapMarkers.value.set(markerData.id, marker); mapMarkers.value.set(markerData.id, marker);
} }
}); });
frameMarkers();
}; };
// Frame all markers in view // Frame all markers in view
@ -130,7 +131,7 @@ const frameMarkers = () => {
lat: props.markers[0].location.latitude, lat: props.markers[0].location.latitude,
lng: props.markers[0].location.longitude, lng: props.markers[0].location.longitude,
}, },
{ duration: 1000 }, { duration: 1000, zoom: 15 },
{ isInternalUpdate: true }, { isInternalUpdate: true },
); );
} else { } else {

View file

@ -13,19 +13,6 @@
margin-bottom: 1rem; margin-bottom: 1rem;
padding-bottom: 0; padding-bottom: 0;
} }
.map-container {
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
height: 500px;
align-items: center;
justify-content: center;
margin-bottom: 20px;
margin-top: 20px;
}
#map {
width: 100%;
height: 100%;
}
.section-heading { .section-heading {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
display: flex; display: flex;
@ -111,9 +98,15 @@ select.tall {
height: 500px; height: 500px;
margin-bottom: 20px; margin-bottom: 20px;
margin-top: 20px; margin-top: 20px;
align-items: center;
justify-content: center;
/* Prevent touch scrolling issues */ /* Prevent touch scrolling issues */
touch-action: pan-y pinch-zoom; touch-action: pan-y pinch-zoom;
} }
#map {
width: 100%;
height: 100%;
}
/* Mobile-specific adjustments */ /* Mobile-specific adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
@ -131,11 +124,6 @@ select.tall {
border-radius: 5px; border-radius: 5px;
} }
} }
#map {
width: 100%;
height: 100%;
}
</style> </style>
<template> <template>

View file

@ -1,15 +1,31 @@
<style scoped> <style scoped>
.map-container {
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
height: 500px;
margin-bottom: 20px;
margin-top: 20px;
align-items: center;
justify-content: center;
/* Prevent touch scrolling issues */
touch-action: pan-y pinch-zoom;
}
#map {
width: 100%;
height: 100%;
}
.status-badge {
font-size: 1rem;
}
.timeline { .timeline {
border-left: 3px solid #dee2e6; border-left: 3px solid #dee2e6;
padding-left: 20px; padding-left: 20px;
margin-left: 10px; margin-left: 10px;
} }
.timeline-item { .timeline-item {
position: relative; position: relative;
margin-bottom: 25px; margin-bottom: 25px;
} }
.timeline-item:before { .timeline-item:before {
content: ""; content: "";
position: absolute; position: absolute;
@ -20,17 +36,14 @@
border-radius: 50%; border-radius: 50%;
background-color: #0d6efd; background-color: #0d6efd;
} }
.timeline-date { .timeline-date {
font-size: 0.85rem; font-size: 0.85rem;
color: #6c757d; color: #6c757d;
} }
.status-badge {
font-size: 1rem;
}
</style> </style>
<template> <template>
<HeaderDistrict v-if="district" />
<Header v-else />
<div class="container my-4" v-if="report"> <div class="container my-4" v-if="report">
<!-- Report ID and Status Section --> <!-- Report ID and Status Section -->
<div class="card mb-4"> <div class="card mb-4">
@ -62,7 +75,7 @@
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<strong><i class="bi bi-pin-map me-2"></i>Location:</strong> <strong><i class="bi bi-pin-map me-2"></i>Location:</strong>
<span>{{ report.address }}</span> <span>{{ report.address.raw }}</span>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
@ -91,6 +104,29 @@
</div> </div>
</div> </div>
</div> </div>
<!-- History Timeline -->
<div class="card">
<div class="card-header bg-success text-white">
<h5 class="mb-0">
<i class="bi bi-clock-history me-2"></i>Request History
</h5>
</div>
<div class="card-body">
<div class="timeline">
<div
v-for="(item, index) in report.log"
:key="index"
class="timeline-item"
>
<div class="timeline-date">
{{ formatTimeRelative(item.created) }}
</div>
<h5 class="mb-1">{{ item.type }}</h5>
<p class="mb-0">{{ item.message }}</p>
</div>
</div>
</div>
</div>
</div> </div>
<div class="container my-4" v-else> <div class="container my-4" v-else>
<p>loading...</p> <p>loading...</p>
@ -99,6 +135,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from "vue"; import { ref, computed, onMounted } from "vue";
import { computedAsync } from "@vueuse/core"; import { computedAsync } from "@vueuse/core";
import Header from "@/rmo/components/Header.vue";
import HeaderDistrict from "@/components/HeaderDistrict.vue";
import MapLocatorDisplay from "@/components/MapLocatorDisplay.vue"; import MapLocatorDisplay from "@/components/MapLocatorDisplay.vue";
import { useStoreDistrict } from "@/rmo/store/district"; import { useStoreDistrict } from "@/rmo/store/district";
import { useStorePublicreport } from "@/store/publicreport"; import { useStorePublicreport } from "@/store/publicreport";
@ -130,7 +168,7 @@ const markers = computed((): Marker[] => {
} }
return [ return [
{ {
id: report.value.id, id: props.id,
location: report.value.location, location: report.value.location,
}, },
]; ];

View file

@ -32,25 +32,49 @@ export interface Geocode {
cell: number; cell: number;
location: Location; location: Location;
} }
export interface LogEntryDTO {
created: string;
message: string;
type: string;
user_id: number;
}
export class LogEntry {
constructor(
public created: Date,
public message: string,
public type: string,
public user_id: number,
) {}
static fromJSON(json: LogEntryDTO): LogEntry {
return new LogEntry(
new Date(json.created),
json.message,
json.type,
json.user_id,
);
}
}
export interface PublicreportDTO { export interface PublicreportDTO {
address: string; address: Address;
created: string; created: string;
district: string; district: string;
id: string; id: string;
image_count: number; image_count: number;
location: Location; location: Location;
log: LogEntryDTO[];
status: string; status: string;
type: string; type: string;
uri: string; uri: string;
} }
export class Publicreport { export class Publicreport {
constructor( constructor(
public address: string, public address: Address,
public created: Date, public created: Date,
public district: string, public district: string,
public id: string, public id: string,
public image_count: number, public image_count: number,
public location: Location, public location: Location,
public log: LogEntry[],
public status: string, public status: string,
public type: string, public type: string,
public uri: string, public uri: string,
@ -63,6 +87,7 @@ export class Publicreport {
json.id, json.id,
json.image_count, json.image_count,
json.location, json.location,
json.log.map((l: LogEntryDTO) => LogEntry.fromJSON(l)),
json.status, json.status,
json.type, json.type,
json.uri, json.uri,