diff --git a/api/review_task.go b/api/review_task.go index 336a1954..3aaf917a 100644 --- a/api/review_task.go +++ b/api/review_task.go @@ -16,22 +16,25 @@ import ( "github.com/stephenafamo/scan" ) -type reviewTaskPool struct { - Address types.Address `json:"address"` - Condition string `json:"condition"` - Created time.Time `json:"created"` - Creator platform.User `json:"creator"` - ID int32 `json:"id"` - Location types.Location `json:"location"` - Reviewed *time.Time `json:"addressed"` - Reviewer *platform.User `json:"addressor"` +type reviewTask struct { + Address types.Address `json:"address"` + Created time.Time `json:"created"` + Creator platform.User `json:"creator"` + ID int32 `json:"id"` + Location types.Location `json:"location"` + Pool reviewTaskPool `json:"pool"` + Reviewed *time.Time `json:"addressed"` + Reviewer *platform.User `json:"addressor"` } -type contentListReviewTaskPool struct { - Tasks []reviewTaskPool `json:"tasks"` - Total int32 `json:"total"` +type reviewTaskPool struct { + Condition string `json:"condition"` +} +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 if query.Limit != nil { limit = *query.Limit @@ -114,23 +117,25 @@ func listReviewTaskPool(ctx context.Context, r *http.Request, user platform.User if err != nil { return nil, nhttp.NewError("users by id: %w", err) } - tasks := make([]reviewTaskPool, len(rows)) + tasks := make([]reviewTask, len(rows)) for i, row := range rows { - tasks[i] = reviewTaskPool{ - Address: row.Address, - Condition: row.Condition, - Created: row.Created, - Creator: *users_by_id[row.CreatorID], - ID: row.ID, + tasks[i] = reviewTask{ + Address: row.Address, + Created: row.Created, + Creator: *users_by_id[row.CreatorID], + ID: row.ID, Location: types.Location{ Latitude: row.Latitude, Longitude: row.Longitude, }, + Pool: reviewTaskPool{ + Condition: row.Condition, + }, Reviewed: row.Reviewed, Reviewer: userOrNil(users_by_id, row.ReviewerID), } } - return &contentListReviewTaskPool{ + return &contentListReviewTask{ Tasks: tasks, Total: row_total.Total, }, nil diff --git a/api/routes.go b/api/routes.go index 2daf69b8..881eb568 100644 --- a/api/routes.go +++ b/api/routes.go @@ -30,7 +30,7 @@ func AddRoutes(r chi.Router) { r.Method("POST", "/publicreport/signal", authenticatedHandlerJSONPost(postPublicreportSignal)) r.Method("POST", "/publicreport/message", authenticatedHandlerJSONPost(postPublicreportMessage)) 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", "/signal", authenticatedHandlerJSON(listSignal)) r.Method("POST", "/sudo/email", authenticatedHandlerJSONPost(postSudoEmail)) diff --git a/api/user.go b/api/user.go index 216317e3..b8e91e22 100644 --- a/api/user.go +++ b/api/user.go @@ -13,6 +13,7 @@ import ( type contentURLAPI struct { Communication string `json:"communication"` PublicreportMessage string `json:"publicreport_message"` + ReviewTask string `json:"review_task"` Signal string `json:"signal"` Upload string `json:"upload"` } @@ -44,6 +45,7 @@ func getUserSelf(ctx context.Context, r *http.Request, user platform.User, query API: contentURLAPI{ Communication: urls.API.Communication, PublicreportMessage: urls.API.Publicreport.Message, + ReviewTask: config.MakeURLNidus("/api/review-task"), Signal: config.MakeURLNidus("/api/signal"), Upload: config.MakeURLNidus("/api/upload"), }, diff --git a/scss/sync/review.scss b/scss/sync/review.scss index 26d106e5..e69de29b 100644 --- a/scss/sync/review.scss +++ b/scss/sync/review.scss @@ -1,3 +0,0 @@ -a.card-link { - text-decoration: none; -} diff --git a/ts/components/PublicreportCard.vue b/ts/components/PublicreportCard.vue index c086edfc..1b9ff4b9 100644 --- a/ts/components/PublicreportCard.vue +++ b/ts/components/PublicreportCard.vue @@ -315,6 +315,7 @@ import { computed } from "vue"; import MapMultipoint from "@/components/MapMultipoint.vue"; import PublicreportCard from "@/components/PublicreportCard.vue"; import TimeRelative from "@/components/TimeRelative.vue"; +import { formatAddress } from "@/format"; interface Emits { (e: "viewImage", index: int): void; @@ -324,12 +325,6 @@ interface Props { } const emit = defineEmits(); const props = defineProps(); -function formatAddress(a) { - if (a.number === "" && a.street === "") { - return "no address provided"; - } - return `${a.number} ${a.street}, ${a.locality}`; -} function openPhotoViewer(index) { emit("viewImage", index); } diff --git a/ts/components/ReviewPoolColumnAction.vue b/ts/components/ReviewPoolColumnAction.vue new file mode 100644 index 00000000..69b4a615 --- /dev/null +++ b/ts/components/ReviewPoolColumnAction.vue @@ -0,0 +1,66 @@ + + diff --git a/ts/components/ReviewPoolColumnDetail.vue b/ts/components/ReviewPoolColumnDetail.vue new file mode 100644 index 00000000..c3a2459b --- /dev/null +++ b/ts/components/ReviewPoolColumnDetail.vue @@ -0,0 +1,151 @@ + + diff --git a/ts/components/ReviewPoolColumnList.vue b/ts/components/ReviewPoolColumnList.vue new file mode 100644 index 00000000..8ff9aaa7 --- /dev/null +++ b/ts/components/ReviewPoolColumnList.vue @@ -0,0 +1,55 @@ + + diff --git a/ts/format.ts b/ts/format.ts index 6e63e98b..12c71362 100644 --- a/ts/format.ts +++ b/ts/format.ts @@ -1,5 +1,30 @@ 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 { if (!a) return ""; return `${a.number} ${a.street}, ${a.locality}`; diff --git a/ts/router.ts b/ts/router.ts index 0e18293c..6d21e297 100644 --- a/ts/router.ts +++ b/ts/router.ts @@ -20,7 +20,8 @@ import NotFound from "./view/NotFound.vue"; import OAuthRefreshArcgis from "./view/OAuthRefreshArcgis.vue"; import Operations from "./view/Operations.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 Sudo from "./view/Sudo.vue"; import apiClient from "@/client"; @@ -138,7 +139,13 @@ const routes: RouteRecordRaw[] = [ { path: "/_/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 }, }, { diff --git a/ts/store/review-task.ts b/ts/store/review-task.ts new file mode 100644 index 00000000..30925556 --- /dev/null +++ b/ts/store/review-task.ts @@ -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>(new Map()); + const all = ref(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 { + 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, + }; +}); diff --git a/ts/view/Communication.vue b/ts/view/Communication.vue index fb2da60f..e9615ddb 100644 --- a/ts/view/Communication.vue +++ b/ts/view/Communication.vue @@ -58,17 +58,17 @@ diff --git a/ts/view/Review.vue b/ts/view/review/Root.vue similarity index 100% rename from ts/view/Review.vue rename to ts/view/review/Root.vue