From 04f2a21681ac10c3787afd0fc8f5a288ac4ad3cf Mon Sep 17 00:00:00 2001 From: Theo Date: Mon, 21 Sep 2026 20:36:46 -0700 Subject: [PATCH 01/17] Shared event + user status labels, drop phantom activity cache keys --- .claude/launch.json | 11 +++++++++++ apps/admin/src/features/discovery/use-discovery.ts | 5 ++--- apps/admin/src/features/events/use-events.ts | 1 - apps/admin/src/features/inbox/use-inbox.ts | 1 - apps/admin/src/features/mail/use-mail.ts | 1 - .../admin/src/features/moderation/use-moderation.ts | 1 - apps/admin/src/features/reports/use-reports.ts | 2 -- apps/admin/src/features/users/use-users.ts | 1 - apps/admin/src/features/users/users-page.tsx | 9 +++++---- apps/admin/src/lib/event-status.test.ts | 12 ++++++------ apps/admin/src/lib/event-status.ts | 10 +++++----- apps/admin/src/lib/query.ts | 13 ++++++------- 12 files changed, 35 insertions(+), 32 deletions(-) create mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..cdae8fd --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "admin", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["--filter", "admin", "dev"], + "port": 3000 + } + ] +} diff --git a/apps/admin/src/features/discovery/use-discovery.ts b/apps/admin/src/features/discovery/use-discovery.ts index 297f023..a3ba073 100644 --- a/apps/admin/src/features/discovery/use-discovery.ts +++ b/apps/admin/src/features/discovery/use-discovery.ts @@ -21,7 +21,7 @@ import { queryKeys } from "@/lib/query" /** * Data hooks for the Discovery / Jurisdictions section (enumeration 2.B). Reads use GET /admin/discovery * (list) and GET /admin/discovery/:id (detail); writes use the discovery + jurisdictions mutations. All - * mutations invalidate the discovery + jurisdictions caches plus the cross-cutting home + activity feeds + * mutations invalidate the discovery + jurisdictions caches plus the cross-cutting home summary * (a saved contact / flag changes the dashboard aggregates), matching the scaffold's documented pattern. * * Query keys: reuses the existing registry in src/lib/query.ts (discovery.list/detail/all, @@ -89,12 +89,11 @@ export function useJurisdictionGeometry(geoid: string | null) { }) } -/** Invalidate every discovery/jurisdiction view plus the home + activity aggregates after a write. */ +/** Invalidate every discovery/jurisdiction view plus the home aggregates after a write. */ function invalidateDiscovery(qc: ReturnType) { qc.invalidateQueries({ queryKey: queryKeys.discovery.all }) qc.invalidateQueries({ queryKey: queryKeys.jurisdictions.all }) qc.invalidateQueries({ queryKey: queryKeys.home.all }) - qc.invalidateQueries({ queryKey: queryKeys.activity.all }) } /** POST /admin/discovery/:id/notes - append an operator note to a discovery task. */ diff --git a/apps/admin/src/features/events/use-events.ts b/apps/admin/src/features/events/use-events.ts index 3c1fdf9..08690eb 100644 --- a/apps/admin/src/features/events/use-events.ts +++ b/apps/admin/src/features/events/use-events.ts @@ -48,7 +48,6 @@ function invalidateEvents(qc: ReturnType, id: string) { qc.invalidateQueries({ queryKey: queryKeys.events.detail(id) }) qc.invalidateQueries({ queryKey: queryKeys.events.all }) qc.invalidateQueries({ queryKey: queryKeys.home.all }) - qc.invalidateQueries({ queryKey: queryKeys.activity.all }) } export function useFlagEvent() { diff --git a/apps/admin/src/features/inbox/use-inbox.ts b/apps/admin/src/features/inbox/use-inbox.ts index b9d0550..d7c6536 100644 --- a/apps/admin/src/features/inbox/use-inbox.ts +++ b/apps/admin/src/features/inbox/use-inbox.ts @@ -44,7 +44,6 @@ function invalidateInbox(qc: ReturnType, id?: string) { if (id) qc.invalidateQueries({ queryKey: queryKeys.inbox.detail(id) }) qc.invalidateQueries({ queryKey: queryKeys.inbox.all }) qc.invalidateQueries({ queryKey: queryKeys.home.all }) - qc.invalidateQueries({ queryKey: queryKeys.activity.all }) } export function useSetInboxStatus() { diff --git a/apps/admin/src/features/mail/use-mail.ts b/apps/admin/src/features/mail/use-mail.ts index 3f4f820..7c693d8 100644 --- a/apps/admin/src/features/mail/use-mail.ts +++ b/apps/admin/src/features/mail/use-mail.ts @@ -61,7 +61,6 @@ function invalidateMail(qc: ReturnType, id?: string) { qc.invalidateQueries({ queryKey: queryKeys.mail.all }) qc.invalidateQueries({ queryKey: queryKeys.mail.stats }) qc.invalidateQueries({ queryKey: queryKeys.home.all }) - qc.invalidateQueries({ queryKey: queryKeys.activity.all }) } export function useComposeMail() { diff --git a/apps/admin/src/features/moderation/use-moderation.ts b/apps/admin/src/features/moderation/use-moderation.ts index 55bca1b..df847b0 100644 --- a/apps/admin/src/features/moderation/use-moderation.ts +++ b/apps/admin/src/features/moderation/use-moderation.ts @@ -47,7 +47,6 @@ function invalidateModeration(qc: ReturnType, id?: string if (id) qc.invalidateQueries({ queryKey: queryKeys.moderation.detail(id) }) qc.invalidateQueries({ queryKey: queryKeys.moderation.all }) qc.invalidateQueries({ queryKey: queryKeys.home.all }) - qc.invalidateQueries({ queryKey: queryKeys.activity.all }) } export function useApproveModeration() { diff --git a/apps/admin/src/features/reports/use-reports.ts b/apps/admin/src/features/reports/use-reports.ts index d59a1de..6119ffa 100644 --- a/apps/admin/src/features/reports/use-reports.ts +++ b/apps/admin/src/features/reports/use-reports.ts @@ -51,7 +51,6 @@ function invalidateReports(qc: ReturnType, id: string) { qc.invalidateQueries({ queryKey: queryKeys.reports.detail(id) }) qc.invalidateQueries({ queryKey: queryKeys.reports.all }) qc.invalidateQueries({ queryKey: queryKeys.home.all }) - qc.invalidateQueries({ queryKey: queryKeys.activity.all }) } export function useSetReportStatus() { @@ -134,7 +133,6 @@ export function useDeleteReportMessage() { mutationFn: (input: DeleteReportMessageRequest) => api.deleteReportMessage(input), onSuccess: (_res, { id }) => { qc.invalidateQueries({ queryKey: queryKeys.reports.chat(id) }) - qc.invalidateQueries({ queryKey: queryKeys.activity.all }) }, }) } diff --git a/apps/admin/src/features/users/use-users.ts b/apps/admin/src/features/users/use-users.ts index 0d68eaf..a6f15ba 100644 --- a/apps/admin/src/features/users/use-users.ts +++ b/apps/admin/src/features/users/use-users.ts @@ -85,7 +85,6 @@ function invalidateUsers(qc: ReturnType, id: string) { qc.invalidateQueries({ queryKey: queryKeys.users.detail(id) }) qc.invalidateQueries({ queryKey: queryKeys.users.all }) qc.invalidateQueries({ queryKey: queryKeys.home.all }) - qc.invalidateQueries({ queryKey: queryKeys.activity.all }) } export function useFlagUser() { diff --git a/apps/admin/src/features/users/users-page.tsx b/apps/admin/src/features/users/users-page.tsx index f999cc1..5e98ec5 100644 --- a/apps/admin/src/features/users/users-page.tsx +++ b/apps/admin/src/features/users/users-page.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { REPORT_CATEGORY_LABELS, RISK_LABELS, + USER_STATUS_LABELS, avatarColor, monogram, type AdminUserDTO, @@ -47,10 +48,10 @@ type NavFn = ReturnType const STATUS_VIEW: Record = { - active: { cls: "status-ok", label: "Active" }, - suspended: { cls: "status-flag", label: "Suspended" }, - review: { cls: "status-progress", label: "In review" }, - banned: { cls: "status-flag", label: "Banned" }, + active: { cls: "status-ok", label: USER_STATUS_LABELS.active }, + suspended: { cls: "status-flag", label: USER_STATUS_LABELS.suspended }, + review: { cls: "status-progress", label: USER_STATUS_LABELS.review }, + banned: { cls: "status-flag", label: USER_STATUS_LABELS.banned }, } const SOURCE_LABEL: Record | "group", string> = { diff --git a/apps/admin/src/lib/event-status.test.ts b/apps/admin/src/lib/event-status.test.ts index 582394f..06046fa 100644 --- a/apps/admin/src/lib/event-status.test.ts +++ b/apps/admin/src/lib/event-status.test.ts @@ -1,17 +1,17 @@ import { describe, expect, it } from "vitest" -import type { EventStatus } from "@civfix/shared" +import { EVENT_STATUS_LABELS, type EventStatus } from "@civfix/shared" import { cancelBlockedFor, EVENT_STATUS_VIEW, eventStatusView } from "./event-status" describe("event status pills", () => { - it("reads the derived statuses as clock readings, not as operator decisions", () => { - expect(eventStatusView("upcoming").label).toBe("Upcoming") - expect(eventStatusView("in_progress").label).toBe("Happening now") - expect(eventStatusView("completed").label).toBe("Ended") + it("renders every label from the shared contract map rather than a local fork", () => { + const statuses: EventStatus[] = ["upcoming", "in_progress", "completed", "cancelled"] + for (const status of statuses) { + expect(eventStatusView(status).label).toBe(EVENT_STATUS_LABELS[status]) + } }) it("keeps cancelled as the one status an operator can produce", () => { - expect(eventStatusView("cancelled").label).toBe("Cancelled") expect(eventStatusView("cancelled").cls).toBe("status-flag") }) diff --git a/apps/admin/src/lib/event-status.ts b/apps/admin/src/lib/event-status.ts index 7c8ec98..2e02fda 100644 --- a/apps/admin/src/lib/event-status.ts +++ b/apps/admin/src/lib/event-status.ts @@ -1,11 +1,11 @@ -import type { EventStatus } from "@civfix/shared" +import { EVENT_STATUS_LABELS, type EventStatus } from "@civfix/shared" /** Pill treatment per event lifecycle status, shared by the Events section and the org events tab. */ export const EVENT_STATUS_VIEW: Record = { - upcoming: { cls: "status-new", label: "Upcoming" }, - in_progress: { cls: "status-progress", label: "Happening now" }, - completed: { cls: "status-ok", label: "Ended" }, - cancelled: { cls: "status-flag", label: "Cancelled" }, + upcoming: { cls: "status-new", label: EVENT_STATUS_LABELS.upcoming }, + in_progress: { cls: "status-progress", label: EVENT_STATUS_LABELS.in_progress }, + completed: { cls: "status-ok", label: EVENT_STATUS_LABELS.completed }, + cancelled: { cls: "status-flag", label: EVENT_STATUS_LABELS.cancelled }, } export function eventStatusView(status: EventStatus): { cls: string; label: string } { diff --git a/apps/admin/src/lib/query.ts b/apps/admin/src/lib/query.ts index e41a382..bb0a6eb 100644 --- a/apps/admin/src/lib/query.ts +++ b/apps/admin/src/lib/query.ts @@ -50,11 +50,6 @@ export const queryKeys = { summary: ["admin", "home", "summary"] as const, map: ["admin", "home", "map"] as const, }, - activity: { - all: ["admin", "activity"] as const, - list: (params?: unknown) => ["admin", "activity", params ?? null] as const, - }, - discovery: { all: ["admin", "discovery"] as const, list: (params?: unknown) => ["admin", "discovery", "list", params ?? null] as const, @@ -71,8 +66,6 @@ export const queryKeys = { list: (params?: unknown) => ["admin", "reports", "list", params ?? null] as const, page: (params?: unknown) => ["admin", "reports", "page", params ?? null] as const, detail: (id: string) => ["admin", "reports", "detail", id] as const, - discussion: (id: string, params?: unknown) => - ["admin", "reports", id, "discussion", params ?? null] as const, chat: (id: string, params?: unknown) => ["admin", "reports", id, "chat", params ?? null] as const, }, @@ -115,6 +108,12 @@ export const queryKeys = { detail: (id: string) => ["admin", "moderation", "detail", id] as const, }, + govClaims: { + all: ["admin", "gov-claims"] as const, + list: (params?: unknown) => ["admin", "gov-claims", "list", params ?? null] as const, + detail: (id: string) => ["admin", "gov-claims", "detail", id] as const, + }, + analytics: { all: ["admin", "analytics"] as const, kpis: ["admin", "analytics", "kpis"] as const, From fb1241af3d305cffcc57b342151c4120fa1f3e00 Mon Sep 17 00:00:00 2001 From: Theo Date: Mon, 21 Sep 2026 20:40:09 -0700 Subject: [PATCH 02/17] Contract-derived categories, report-type captions, Jurisdictions tabs and save labels --- .../src/features/discovery/discovery-page.tsx | 66 ++++++++++++------- .../discovery/discovery-ui-state.test.ts | 2 +- apps/admin/src/lib/category.test.ts | 20 ++++++ apps/admin/src/lib/category.ts | 43 +++++++++++- apps/admin/src/styles/admin.css | 1 + apps/admin/src/styles/colors-and-type.css | 15 +++-- 6 files changed, 114 insertions(+), 33 deletions(-) diff --git a/apps/admin/src/features/discovery/discovery-page.tsx b/apps/admin/src/features/discovery/discovery-page.tsx index 9c2acdf..32b1e6b 100644 --- a/apps/admin/src/features/discovery/discovery-page.tsx +++ b/apps/admin/src/features/discovery/discovery-page.tsx @@ -14,7 +14,12 @@ import { import { Icons } from "@/components/icons" import { toAppError } from "@/lib/api" -import { categoryLabel, categoryPinSrc } from "@/lib/category" +import { + REPORT_CATEGORIES, + categoryLabel, + categoryPinSrc, + categoryReportTypes, +} from "@/lib/category" import { promptDialog } from "@/components/shared/dialog" import { PageHead, FilterChips, EmptyState } from "@/components/shared/page-primitives" import { LoadingState, ErrorState } from "@/components/shared/data-states" @@ -89,19 +94,13 @@ function isOverdue(iso: string | null): boolean { return Date.now() - then > 24 * HOUR_MS } -const CATEGORIES: readonly ReportCategory[] = [ - "trash", - "recycling", - "graffiti", - "hazard", - "encampment", - "water", - "other", -] - -const REPORT_TYPES: { id: ReportCategory; label: string; pin: string }[] = CATEGORIES.map( - (id) => ({ id, label: categoryLabel(id), pin: categoryPinSrc(id) }), -) +const REPORT_TYPES: { id: ReportCategory; label: string; pin: string; types: string }[] = + REPORT_CATEGORIES.map((id) => ({ + id, + label: categoryLabel(id), + pin: categoryPinSrc(id), + types: categoryReportTypes(id), + })) function routingCount(counts: PerCategoryCounts, id: ReportCategory): number { return counts[id] ?? 0 @@ -110,7 +109,7 @@ function routingCount(counts: PerCategoryCounts, id: ReportCategory): number { function dominantCategory(counts: PerCategoryCounts): ReportCategory | null { let best: ReportCategory | null = null let bestN = 0 - for (const c of CATEGORIES) { + for (const c of REPORT_CATEGORIES) { const n = counts[c] ?? 0 if (n > bestN) { bestN = n @@ -371,7 +370,7 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) { if (!canSave) return saveContacts.mutate( { geoid: dto.geoid, contacts: contactsPayload(), ...jurisdictionFields() }, - { onSuccess: () => toast(`Contacts saved for ${dto.org}`) }, + { onSuccess: () => toast(`Contacts saved and routed for ${dto.org}`) }, ) } @@ -554,6 +553,7 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) { {n} {n === 1 ? "report" : "reports"} +
{c.types}
- Counts are reports waiting per type · highlighted types have reports but no contact yet. + Counts are reports waiting per category · the grey line lists the report types neighbors + pick that fold into it · highlighted categories have reports but no contact yet.
@@ -618,20 +619,37 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) { - +
+ + + Save & route saves the contacts, closes the discovery task, and queues outreach to + this jurisdiction. Save draft saves contacts without routing or closing the task. + +
+ setTemplateOpen(false)} @@ -729,7 +747,7 @@ function UnmappedDetail({ dto }: { dto: JurisdictionDirectoryDTO }) { } export function DiscoveryPage({ focusId }: SectionPageProps) { - const [filter, setFilter] = React.useState("all") + const [filter, setFilter] = React.useState("attention") const [layer, setLayer] = React.useState<"all" | JurisdictionLayer>("all") const [sort, setSort] = React.useState("pop") const [query, setQuery] = React.useState(focusId ?? "") @@ -785,14 +803,14 @@ export function DiscoveryPage({ focusId }: SectionPageProps) { const selected = focused ?? (holdingFocus ? null : (items[0] ?? null)) const catFilters = [ - { value: "all", label: "All", count: allTotal ?? 0 }, - { value: "attention", label: "Need mapping", count: needsMappingCountDisplay }, + { value: "attention", label: "Needs mapping", count: needsMappingCountDisplay }, { value: "clear", label: "Routed", count: facets?.routed ?? 0 }, + { value: "all", label: "All", count: allTotal ?? 0 }, ] const onFilterChange = (v: JurisdictionFilter) => { setFilter(v) - // The "need mapping" view is about the most-overdue reports first, so default it to the + // The "needs mapping" view is about the most-overdue reports first, so default it to the // oldest-first sort; leaving the view falls back to population unless the operator picked reports. if (v === "attention") setSort("oldest") else if (sort === "oldest") setSort("pop") diff --git a/apps/admin/src/features/discovery/discovery-ui-state.test.ts b/apps/admin/src/features/discovery/discovery-ui-state.test.ts index 02cd2e7..8f9e9a8 100644 --- a/apps/admin/src/features/discovery/discovery-ui-state.test.ts +++ b/apps/admin/src/features/discovery/discovery-ui-state.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest" import { getJurisdictionSort, getNeedsMappingCountDisplay } from "./discovery-ui-state" describe("Jurisdictions UI state", () => { - it("always uses oldest sort for the Need mapping filter", () => { + it("always uses oldest sort for the Needs mapping filter", () => { expect(getJurisdictionSort("attention", "pop")).toBe("oldest") expect(getJurisdictionSort("attention", "reports")).toBe("oldest") expect(getJurisdictionSort("attention", "oldest")).toBe("oldest") diff --git a/apps/admin/src/lib/category.test.ts b/apps/admin/src/lib/category.test.ts index 0577c0c..2c5f40b 100644 --- a/apps/admin/src/lib/category.test.ts +++ b/apps/admin/src/lib/category.test.ts @@ -5,9 +5,11 @@ import { describe, expect, it } from "vitest" import { CATEGORY_GLYPHS, + REPORT_CATEGORIES, categoryCssVar, categoryLabel, categoryPinSrc, + categoryReportTypes, } from "@/lib/category" const PUBLIC_DIR = new URL("../../public/", import.meta.url) @@ -36,4 +38,22 @@ describe("category presentation", () => { expect(categoryLabel(category)).toBe(REPORT_CATEGORY_LABELS[category]) } }) + + it("derives the category list from the contract enum, in enum order", () => { + expect(REPORT_CATEGORIES).toEqual(CATEGORIES) + }) +}) + +describe("category report types", () => { + it("folds the contract report types and the web picker's finer types into one caption", () => { + expect(categoryReportTypes("hazard")).toBe("Pavement distress") + expect(categoryReportTypes("recycling")).toBe("Overgrown vegetation, Recycling") + expect(categoryReportTypes("water")).toBe("Broken infrastructure, Water/leak") + }) + + it("lists at least one report type for every category", () => { + for (const category of CATEGORIES) { + expect(categoryReportTypes(category)).not.toBe("") + } + }) }) diff --git a/apps/admin/src/lib/category.ts b/apps/admin/src/lib/category.ts index 5190aa7..15dc8cf 100644 --- a/apps/admin/src/lib/category.ts +++ b/apps/admin/src/lib/category.ts @@ -1,4 +1,15 @@ -import { REPORT_CATEGORY_LABELS, type ReportCategory } from "@civfix/shared" +import { + REPORT_CATEGORY_LABELS, + REPORT_TYPE_LABELS, + REPORT_TYPE_TO_CATEGORY, + REPORT_TYPE_VALUES, + ReportCategorySchema, + WEB_REPORT_TYPES, + type ReportCategory, +} from "@civfix/shared" + +/** The canonical report categories, in the order the contract enum defines them. */ +export const REPORT_CATEGORIES: readonly ReportCategory[] = ReportCategorySchema.options export const CATEGORY_GLYPHS: Record = { trash: @@ -23,3 +34,33 @@ export function categoryPinSrc(category: ReportCategory): string { export function categoryLabel(category: ReportCategory): string { return REPORT_CATEGORY_LABELS[category] } + +function buildCategoryReportTypeLabels(): Record { + const out = Object.fromEntries(REPORT_CATEGORIES.map((c) => [c, [] as string[]])) as Record< + ReportCategory, + string[] + > + const add = (category: ReportCategory, label: string) => { + const bucket = out[category] + if (bucket && !bucket.includes(label)) bucket.push(label) + } + for (const type of REPORT_TYPE_VALUES) { + add(REPORT_TYPE_TO_CATEGORY[type], REPORT_TYPE_LABELS[type]) + } + for (const webType of WEB_REPORT_TYPES) { + add(webType.category, webType.label) + } + return out +} + +/** + * The resident-facing report types that fold into each canonical category, derived from the contract's + * type -> category mapping plus the web picker's finer types. Lets the operator read a routing contact + * against what a neighbor actually picked. + */ +export const CATEGORY_REPORT_TYPE_LABELS: Record = + buildCategoryReportTypeLabels() + +export function categoryReportTypes(category: ReportCategory): string { + return CATEGORY_REPORT_TYPE_LABELS[category].join(", ") +} diff --git a/apps/admin/src/styles/admin.css b/apps/admin/src/styles/admin.css index 01b5d5b..0db1525 100644 --- a/apps/admin/src/styles/admin.css +++ b/apps/admin/src/styles/admin.css @@ -1696,6 +1696,7 @@ button:focus-visible { outline: none; box-shadow: var(--ring); } .ccat-pin { width: 22px; height: 22px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; } .ccat-pin img { width: 100%; height: 100%; object-fit: contain; } .ccat-label { font-weight: 700; font-size: 12.5px; color: var(--ink); letter-spacing: -0.005em; } +.ccat-types { font-size: 11px; color: var(--ink-3); line-height: 1.4; margin-top: -2px; } .ccat-count { margin-left: auto; flex-shrink: 0; diff --git a/apps/admin/src/styles/colors-and-type.css b/apps/admin/src/styles/colors-and-type.css index 0ac309b..78ee9d0 100644 --- a/apps/admin/src/styles/colors-and-type.css +++ b/apps/admin/src/styles/colors-and-type.css @@ -88,14 +88,15 @@ --danger: var(--bloom-600); /* Map / category colors - used for pin types */ - --cat-trash: #8A8378; /* warm gray */ - --cat-recycling: var(--moss); - --cat-graffiti: var(--lilac); - --cat-hazard: var(--bloom); - --cat-encampment: #2FA39A; /* teal - its own category, split out of hazard */ - --cat-cleanup: var(--sun-600); - --cat-water: var(--sky); + /* These mirror tokens.color.category (light scheme) in @civfix/shared - keep the two identical. */ + --cat-trash: #776C60; + --cat-recycling: #63A45A; + --cat-graffiti: #9B7ED9; + --cat-hazard: #E4574A; + --cat-encampment: #3E9E8E; + --cat-water: #74A9D8; --cat-other: #8D8577; + --cat-cleanup: var(--sun-600); /* ==================================================================== TYPOGRAPHY - Families From 6c271652984c528209e79b34f7bc7842e60e367a Mon Sep 17 00:00:00 2001 From: Theo Date: Mon, 21 Sep 2026 20:42:56 -0700 Subject: [PATCH 03/17] One-step verify and send to city, city-only report follow-up, Reports tabs --- .../src/features/reports/reports-page.tsx | 248 ++++++++---------- .../src/features/reports/route-action.test.ts | 12 +- .../src/features/reports/route-action.ts | 9 + apps/admin/src/lib/report-status.ts | 11 +- 4 files changed, 139 insertions(+), 141 deletions(-) diff --git a/apps/admin/src/features/reports/reports-page.tsx b/apps/admin/src/features/reports/reports-page.tsx index f98ddf2..23f1acd 100644 --- a/apps/admin/src/features/reports/reports-page.tsx +++ b/apps/admin/src/features/reports/reports-page.tsx @@ -23,7 +23,7 @@ import { categoryCssVar, categoryPinSrc } from "@/lib/category" import { reportStatusView } from "@/lib/report-status" import { eventKindView } from "@/lib/event-kind" import { getReporterProfileId } from "@/features/reports/reporter-navigation" -import { routeActionFor, type RouteAction } from "@/features/reports/route-action" +import { routeActionFor, routeSendLabel, type RouteAction } from "@/features/reports/route-action" import { useDebounced } from "@/hooks/use-debounced" import { useDeleteReportMessage, @@ -135,12 +135,24 @@ function msgWhen(iso: string): string { return Number.isNaN(d.getTime()) ? iso : d.toLocaleString() } -function routeButtonLabel(action: RouteAction): string { +function routeButtonLabel(action: RouteAction, verdictApproved: boolean): string { if (action.kind === "already_sent") { return action.routedAt ? `Already sent · ${msgWhen(action.routedAt)}` : "Already sent" } if (action.kind === "resend") return "Send again to jurisdiction" - return "Approve & send to jurisdiction" + return routeSendLabel(verdictApproved) +} + +const VERDICT_PILL = { + approved: { cls: "status-ok", label: "Approved" }, + rejected: { cls: "status-flag", label: "Rejected" }, + unreviewed: { cls: "status-new", label: "Not yet reviewed" }, +} as const + +function verdictPill(verdict: AdminReportDTO["verificationVerdict"], approvedLocally: boolean) { + if (verdict === "approved" || approvedLocally) return VERDICT_PILL.approved + if (verdict === "rejected") return VERDICT_PILL.rejected + return VERDICT_PILL.unreviewed } function chatAuthorName(msg: ChatMessageDTO): string { @@ -440,11 +452,10 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i const route = useRouteReport() const verdict = useSetReportVerdict() - const [to, setTo] = React.useState<"reporter" | "city">("reporter") const [text, setText] = React.useState("") const [routeOpen, setRouteOpen] = React.useState(false) const [routeNote, setRouteNote] = React.useState("") - const [lastThreadId, setLastThreadId] = React.useState(null) + const [approvedLocally, setApprovedLocally] = React.useState(false) if (q.isLoading) return if (q.isError) return q.refetch()} /> @@ -458,9 +469,14 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i const routeAction = routeActionFor(report) const statusActions = ADMIN_REPORT_STATUS_TRANSITIONS[report.status] const reporterProfileId = getReporterProfileId(report.reporter.id) - const canReporter = reporterProfileId !== null - const target: "reporter" | "city" = to === "reporter" && !canReporter ? "city" : to - const canSend = target === "reporter" ? canReporter : canCity + const verdictApproved = report.verificationVerdict === "approved" || approvedLocally + const verdictView = verdictPill(report.verificationVerdict, approvedLocally) + const followupBlocked = !canCity + ? "No city contact on file" + : report.outreach.threadId === null + ? "Send the report to the city first — a follow-up goes on that conversation" + : null + const canSend = followupBlocked === null const pin = categoryPinSrc(report.category) const previewMedia = report.media.find((m) => m.kind === "image") ?? report.media[0] const photoUrl = previewMedia @@ -476,11 +492,11 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i const body = text.trim() if (!body || !canSend) return followup.mutate( - { id: report.id, to: target, body }, + { id: report.id, to: "city", body }, { onSuccess: () => { setText("") - toast(`Follow-up sent to ${target === "reporter" ? "reporter" : "city"}`) + toast("Follow-up sent to city") }, }, ) @@ -491,16 +507,7 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i setRouteOpen(true) } - const sendToJurisdiction = async () => { - const contact = report.city.contact - if (!contact || route.isPending) return - const note = routeNote.trim() - const ok = await confirmDialog({ - title: "Send to the city?", - body: `This emails the report to ${contact}. Attached photos are included.`, - confirmLabel: "Send", - }) - if (!ok) return + const routeToJurisdiction = (note: string) => { route.mutate( { id: report.id, @@ -510,13 +517,39 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i onSuccess: (res) => { setRouteOpen(false) setRouteNote("") - setLastThreadId(res.threadId) toast(`Sent to ${res.routedTo}`) }, }, ) } + const sendToJurisdiction = async () => { + const contact = report.city.contact + if (!contact || route.isPending || verdict.isPending) return + const note = routeNote.trim() + const ok = await confirmDialog({ + title: verdictApproved ? "Send to the city?" : "Verify and send to the city?", + body: verdictApproved + ? `This emails the report to ${contact}. Attached photos are included.` + : `This approves the report's verification, then emails it to ${contact}. Attached photos are included.`, + confirmLabel: verdictApproved ? "Send" : "Verify and send", + }) + if (!ok) return + if (verdictApproved) { + routeToJurisdiction(note) + return + } + verdict.mutate( + { id: report.id, verdict: "approved" }, + { + onSuccess: () => { + setApprovedLocally(true) + routeToJurisdiction(note) + }, + }, + ) + } + const onStatus = async (status: AdminReportStatus) => { if (status === report.status) return if (status === "held") { @@ -566,21 +599,21 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i ) } - const onVerdict = async (v: "approved" | "rejected") => { - if (v === "rejected") { - const ok = await confirmDialog({ - title: "Reject report", - body: "This rejects the report's verification verdict.", - danger: true, - confirmLabel: "Reject", - }) - if (!ok) return - } + const onReject = async () => { + const ok = await confirmDialog({ + title: "Reject report", + body: "This rejects the report's verification verdict. It is not sent to the city.", + danger: true, + confirmLabel: "Reject", + }) + if (!ok) return verdict.mutate( - { id: report.id, verdict: v }, + { id: report.id, verdict: "rejected" }, { - onSuccess: () => - toast(`${shortId(report.id)} · ${v === "approved" ? "approved" : "rejected"}`), + onSuccess: () => { + setApprovedLocally(false) + toast(`${shortId(report.id)} · rejected`) + }, }, ) } @@ -907,26 +940,13 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i { }
- Verification - - {report.verificationVerdict === "approved" - ? "Approved" - : report.verificationVerdict === "rejected" - ? "Rejected" - : "Not yet reviewed"} + Send to jurisdiction + + {verdictView.label}
-
+
Reporter:{" "} {report.reporterReportVerified ? "report-verified" : "not report-verified"} @@ -940,29 +960,6 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i )}
-
- - -
-
-
- - { } -
-
Send to jurisdiction
-
{routeAction.kind === "no_contact" || routeAction.kind === "no_jurisdiction" ? ( <>
@@ -981,35 +978,23 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i )} ) : !routeOpen ? ( - <> - - {lastThreadId && ( - - )} - + ) : ( <>
@@ -1027,14 +1012,14 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i
)} +
{ }
-
Send a follow-up
+
Message the city
-
- - +
+ + {report.city.dept}