diff --git a/app/api/resource-edits/methods.ts b/app/api/resource-edits/methods.ts index d76a1dc..266dccf 100644 --- a/app/api/resource-edits/methods.ts +++ b/app/api/resource-edits/methods.ts @@ -1,7 +1,9 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import type { EditReviewStatus, + ResourceChangeLogEntry, ResourceEdit, + ResourceEditCount, ResourceEditQueueRow, } from "~/types/ResourceEdit"; import type { ResourceEntry } from "~/types/ResourceEntry"; @@ -38,6 +40,46 @@ export const getResourceEditAPI = (client: SupabaseClient) => { return (data ?? []) as ResourceEdit[]; }, + /** Count of PENDING edits per resource — flags resources with multiple + * competing proposed edits. Backed by `resource_edit_counts`. */ + getEditCounts: async () => { + const { data, error } = await client + .from("resource_edit_counts") + .select("*"); + + if (error) { + throw error; + } + + return (data ?? []) as ResourceEditCount[]; + }, + /** Full history of decided (APPROVED/REJECTED) edits, most recent first + * within each resource. Backed by `resource_change_log`. */ + getChangeLog: async () => { + const { data, error } = await client + .from("resource_change_log") + .select("*"); + + if (error) { + throw error; + } + + return (data ?? []) as ResourceChangeLogEntry[]; + }, + /** Decided edit history for a single resource, e.g. for the review + * detail page's audit trail. Backed by `resource_change_log`. */ + getChangeLogForResource: async (resourceId: number) => { + const { data, error } = await client + .from("resource_change_log") + .select("*") + .eq("resource_id", resourceId); + + if (error) { + throw error; + } + + return (data ?? []) as ResourceChangeLogEntry[]; + }, getList: async (params: { review_status?: EditReviewStatus } = {}) => { let query = table.select("*").order("submitted_at", { ascending: false }); diff --git a/app/routes/authenticated/_layout.tsx b/app/routes/authenticated/_layout.tsx index 959568c..9cc5309 100644 --- a/app/routes/authenticated/_layout.tsx +++ b/app/routes/authenticated/_layout.tsx @@ -1,7 +1,7 @@ import { Dashboard, Logout, RateReview } from "@mui/icons-material"; import { Box } from "@mui/material"; import { styled } from "@mui/material/styles"; -import { Link, NavLink, Outlet } from "react-router"; +import { Form, NavLink, Outlet } from "react-router"; import phlasklogo from "~/assets/PHLASK_v2.svg"; import { ThemeToggle } from "~/components/ThemeToggle"; import { WaveDivider } from "~/components/WaveDivider"; @@ -168,30 +168,37 @@ export default function DashboardLayout() { - ({ - display: "flex", - alignItems: "center", - gap: 1.25, - borderRadius: 3, - px: 2, - py: 1.25, - fontSize: "0.875rem", - fontWeight: 500, - textDecoration: "none", - color: navy[600], - transition: "background-color 0.2s ease", - "&:hover": { bgcolor: brand[50] }, - ...theme.applyStyles("dark", { - color: `${brand[100]}cc`, - "&:hover": { bgcolor: navy[800] }, - }), - })} - > - - Logout + + ({ + display: "flex", + width: "100%", + alignItems: "center", + gap: 1.25, + borderRadius: 3, + border: "none", + bgcolor: "transparent", + px: 2, + py: 1.25, + fontSize: "0.875rem", + fontFamily: "inherit", + fontWeight: 500, + textAlign: "left", + cursor: "pointer", + color: navy[600], + transition: "background-color 0.2s ease", + "&:hover": { bgcolor: brand[50] }, + ...theme.applyStyles("dark", { + color: `${brand[100]}cc`, + "&:hover": { bgcolor: navy[800] }, + }), + })} + > + + Logout + diff --git a/app/routes/authenticated/dashboard.tsx b/app/routes/authenticated/dashboard.tsx index 4323b0c..d90430f 100644 --- a/app/routes/authenticated/dashboard.tsx +++ b/app/routes/authenticated/dashboard.tsx @@ -29,29 +29,40 @@ const RESOURCE_TYPES: ResourceType[] = ["WATER", "FOOD", "FORAGE", "BATHROOM"]; export const loader: LoaderFunction = async ({ request }) => { const { client } = getDatabaseClient(request); const editAPI = getResourceEditAPI(client); - const edits = await editAPI.getList(); + + // Every `resource_edits` row is either still PENDING (covered by the two + // queue views) or has been decided (covered by `resource_change_log`), so + // this trio fully reconstructs the stats below without ever scanning the + // full `resource_edits` table (with all its wide proposed-value columns). + const [pendingEdits, newResources, changeLog] = await Promise.all([ + editAPI.getEditsQueue(), + editAPI.getNewResourcesQueue(), + editAPI.getChangeLog(), + ]); + + const pending = [...pendingEdits, ...newResources]; const submitterCounts = new Map(); const approverCounts = new Map(); const outstandingByType = new Map(); - let pendingCount = 0; - for (const edit of edits) { - const creator = edit.creator || "Unknown"; - submitterCounts.set(creator, (submitterCounts.get(creator) ?? 0) + 1); + for (const edit of pending) { + const submitter = edit.submitted_by || "Unknown"; + submitterCounts.set(submitter, (submitterCounts.get(submitter) ?? 0) + 1); + outstandingByType.set( + edit.resource_type, + (outstandingByType.get(edit.resource_type) ?? 0) + 1, + ); + } - if (edit.review_status === "PENDING") { - pendingCount += 1; - outstandingByType.set( - edit.resource_type, - (outstandingByType.get(edit.resource_type) ?? 0) + 1, - ); - } + for (const entry of changeLog) { + const submitter = entry.submitted_by || "Unknown"; + submitterCounts.set(submitter, (submitterCounts.get(submitter) ?? 0) + 1); - if (edit.review_status === "APPROVED" && edit.reviewed_by) { + if (entry.review_status === "APPROVED" && entry.reviewed_by) { approverCounts.set( - edit.reviewed_by, - (approverCounts.get(edit.reviewed_by) ?? 0) + 1, + entry.reviewed_by, + (approverCounts.get(entry.reviewed_by) ?? 0) + 1, ); } } @@ -72,8 +83,8 @@ export const loader: LoaderFunction = async ({ request }) => { })); return { - totalEdits: edits.length, - pendingCount, + totalEdits: pending.length + changeLog.length, + pendingCount: pending.length, topSubmitters, topApprovers, outstanding, diff --git a/app/routes/authenticated/logout.tsx b/app/routes/authenticated/logout.tsx index e2d000d..e0ea3db 100644 --- a/app/routes/authenticated/logout.tsx +++ b/app/routes/authenticated/logout.tsx @@ -1,10 +1,18 @@ -import { type LoaderFunction, redirect } from "react-router"; +import { + type ActionFunction, + type LoaderFunction, + redirect, +} from "react-router"; import { getDatabaseClient } from "~/api/client.server"; -export const loader: LoaderFunction = async ({ request }) => { - const { client } = getDatabaseClient(request); +// Signing out is a mutation, so it belongs in the action (POST), not a GET +// loader. A direct GET to this route just bounces back to the dashboard. +export const loader: LoaderFunction = () => redirect("/"); + +export const action: ActionFunction = async ({ request }) => { + const { client, headers } = getDatabaseClient(request); await client.auth.signOut(); - return redirect("/auth"); + return redirect("/auth", { headers }); }; diff --git a/app/routes/authenticated/reviews/detail.tsx b/app/routes/authenticated/reviews/detail.tsx index 5669d93..8f3257c 100644 --- a/app/routes/authenticated/reviews/detail.tsx +++ b/app/routes/authenticated/reviews/detail.tsx @@ -16,7 +16,7 @@ import { TextField, Typography, } from "@mui/material"; -import { useEffect, useState } from "react"; +import { type SubmitEvent, useEffect, useState } from "react"; import { type ActionFunction, data, @@ -33,7 +33,10 @@ import { getResourceEditAPI } from "~/api/resource-edits/methods"; import { getResourceEntryAPI } from "~/api/resources/methods"; import { userContext } from "~/context/user"; import { authMiddleware } from "~/middleware/auth"; -import type { ResourceEdit } from "~/types/ResourceEdit"; +import type { + ResourceChangeLogEntry, + ResourceEdit, +} from "~/types/ResourceEdit"; import type { BathroomTag, DispenserType, @@ -173,6 +176,7 @@ export const loader: LoaderFunction = async ({ request, params }) => { const edit = await editAPI.getById(id); let resource: ResourceEntry | null = null; + let changeLog: ResourceChangeLogEntry[] = []; if (edit.mapped_resource !== null) { try { const resourceAPI = getResourceEntryAPI(client); @@ -180,9 +184,11 @@ export const loader: LoaderFunction = async ({ request, params }) => { } catch { resource = null; } + + changeLog = await editAPI.getChangeLogForResource(edit.mapped_resource); } - return { edit, resource }; + return { edit, resource, changeLog }; }; export const action: ActionFunction = async ({ request, params, context }) => { @@ -238,9 +244,10 @@ export const action: ActionFunction = async ({ request, params, context }) => { }; const ReviewDetail = () => { - const { edit, resource } = useLoaderData<{ + const { edit, resource, changeLog } = useLoaderData<{ edit: ResourceEdit; resource: ResourceEntry | null; + changeLog: ResourceChangeLogEntry[]; }>(); const actionData = useActionData<{ message?: string }>(); const fetcher = useFetcher<{ message?: string; ok?: boolean }>(); @@ -282,7 +289,13 @@ const ReviewDetail = () => { value: EditableValues["bathroom"][K], ) => setValues((v) => ({ ...v, bathroom: { ...v.bathroom, [key]: value } })); - const handleSave = () => { + const handleSave = (event: SubmitEvent) => { + // The payload is a nested object (not flat form fields), so it's sent as + // JSON via the fetcher rather than a native form-encoded submission — + // still routed through a real below for the submit + // semantics (Enter-to-submit, pending state, progressive enhancement). + event.preventDefault(); + const payload: Partial = { name: values.name || null, resource_type: values.resource_type, @@ -380,6 +393,45 @@ const ReviewDetail = () => { )} + {!isNew && changeLog.length > 0 && ( + + + Change history + + + + + {changeLog.map((entry) => ( + + + + + + {entry.reviewed_by ?? "—"} + + + {entry.reviewed_at + ? new Date(entry.reviewed_at).toLocaleString( + undefined, + { dateStyle: "medium", timeStyle: "short" }, + ) + : "—"} + + + {entry.review_notes ?? "—"} + + + ))} + +
+
+
+ )} + @@ -776,14 +828,16 @@ const ReviewDetail = () => { {isPending && ( - + + +
diff --git a/app/routes/authenticated/reviews/index.tsx b/app/routes/authenticated/reviews/index.tsx index cb32608..7fbada9 100644 --- a/app/routes/authenticated/reviews/index.tsx +++ b/app/routes/authenticated/reviews/index.tsx @@ -1,6 +1,7 @@ import { Chip, Paper, + Stack, Table, TableBody, TableCell, @@ -22,7 +23,11 @@ import { type LoaderFunction, useLoaderData, useNavigate } from "react-router"; import { getDatabaseClient } from "~/api/client.server"; import { getResourceEditAPI } from "~/api/resource-edits/methods"; import { authMiddleware } from "~/middleware/auth"; -import type { ResourceEdit, ResourceEditQueueRow } from "~/types/ResourceEdit"; +import type { + ResourceEdit, + ResourceEditCount, + ResourceEditQueueRow, +} from "~/types/ResourceEdit"; import { resourceTypeChipColor, resourceTypeChipIcon, @@ -34,36 +39,45 @@ export const loader: LoaderFunction = async ({ request }) => { const { client } = getDatabaseClient(request); const editAPI = getResourceEditAPI(client); - const [edits, newResources] = await Promise.all([ + const [edits, newResources, editCounts] = await Promise.all([ editAPI.getEditsQueue(), editAPI.getNewResourcesQueue(), + editAPI.getEditCounts(), ]); - return { edits, newResources }; + return { edits, newResources, editCounts }; }; -type RowData = { +type EditRowData = { id: number; - kind: "EDIT" | "NEW"; name: string | null; resource_type: ResourceEdit["resource_type"]; resourceLabel: string; submitted_at: string; + /** How many PENDING edits (including this one) target the same resource. */ + competingEdits: number; }; -const columns: ColumnDef[] = [ - { - accessorKey: "kind", - header: "Kind", - cell: ({ row }) => ( - - ), - }, +type NewRowData = { + id: number; + name: string | null; + resource_type: ResourceEdit["resource_type"]; + submitted_at: string; +}; + +const submittedColumn = < + T extends { submitted_at: string }, +>(): ColumnDef => ({ + accessorKey: "submitted_at", + header: "Submitted", + cell: ({ row }) => + new Date(row.original.submitted_at).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }), +}); + +const editColumns: ColumnDef[] = [ { accessorKey: "name", header: "Proposed name", @@ -86,31 +100,138 @@ const columns: ColumnDef[] = [ header: "Existing resource", }, { - accessorKey: "submitted_at", - header: "Submitted", + accessorKey: "competingEdits", + header: "Pending edits", cell: ({ row }) => - new Date(row.original.submitted_at).toLocaleString(undefined, { - dateStyle: "medium", - timeStyle: "short", - }), + row.original.competingEdits > 1 ? ( + + ) : ( + row.original.competingEdits || "—" + ), }, + submittedColumn(), ]; -const ReviewsQueue = () => { - const { edits, newResources } = useLoaderData<{ - edits: ResourceEditQueueRow[]; - newResources: ResourceEdit[]; - }>(); +const newColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Proposed name", + cell: ({ row }) => row.original.name || "—", + }, + { + accessorKey: "resource_type", + header: "Type", + cell: ({ row }) => ( + + ), + }, + submittedColumn(), +]; + +type ReviewQueueTableProps = { + data: T[]; + columns: ColumnDef[]; + emptyMessage: string; +}; + +function ReviewQueueTable({ + data, + columns, + emptyMessage, +}: ReviewQueueTableProps) { const navigate = useNavigate(); const [sorting, setSorting] = useState([ { id: "submitted_at", desc: true }, ]); - const data: RowData[] = useMemo( - () => [ - ...edits.map((edit) => ({ + const table = useReactTable({ + data, + columns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + return ( + +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {{ asc: " ↑", desc: " ↓" }[ + header.column.getIsSorted() as string + ] ?? null} + + ))} + + ))} + + + {table.getRowModel().rows.length === 0 && ( + + + + {emptyMessage} + + + + )} + {table.getRowModel().rows.map((row) => ( + navigate(`/reviews/${row.original.id}`)} + hover + sx={{ cursor: "pointer" }} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+
+ ); +} + +const ReviewsQueue = () => { + const { edits, newResources, editCounts } = useLoaderData<{ + edits: ResourceEditQueueRow[]; + newResources: ResourceEdit[]; + editCounts: ResourceEditCount[]; + }>(); + + const pendingCountByResource = useMemo( + () => new Map(editCounts.map((c) => [c.resource_id, c.pending_count])), + [editCounts], + ); + + const editData: EditRowData[] = useMemo( + () => + edits.map((edit) => ({ id: edit.id, - kind: "EDIT" as const, name: edit.name ?? null, resource_type: edit.resource_type, resourceLabel: @@ -118,88 +239,58 @@ const ReviewsQueue = () => { edit.current_resource?.address || `Resource #${edit.mapped_resource}`, submitted_at: edit.submitted_at, + competingEdits: edit.mapped_resource + ? (pendingCountByResource.get(edit.mapped_resource) ?? 1) + : 0, })), - ...newResources.map((edit) => ({ + [edits, pendingCountByResource], + ); + + const newData: NewRowData[] = useMemo( + () => + newResources.map((edit) => ({ id: edit.id, - kind: "NEW" as const, name: edit.name ?? null, resource_type: edit.resource_type, - resourceLabel: "—", submitted_at: edit.submitted_at, })), - ], - [edits, newResources], + [newResources], ); - const table = useReactTable({ - data, - columns, - state: { sorting }, - onSortingChange: setSorting, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - }); return ( -
- - Resource reviews - - - Proposed edits to existing PHLask resources and brand-new site - submissions, pending approval. - - - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {flexRender( - header.column.columnDef.header, - header.getContext(), - )} - {{ asc: " ↑", desc: " ↓" }[ - header.column.getIsSorted() as string - ] ?? null} - - ))} - - ))} - - - {table.getRowModel().rows.length === 0 && ( - - - - No pending reviews. Nothing to do here right now. - - - - )} - {table.getRowModel().rows.map((row) => ( - navigate(`/reviews/${row.original.id}`)} - hover - sx={{ cursor: "pointer" }} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} - -
-
-
+ +
+ + Resource reviews + + + Proposed edits to existing PHLask resources and brand-new site + submissions, pending approval. + +
+ +
+ + New site submissions + + +
+ +
+ + Edits to existing resources + + +
+
); }; diff --git a/app/types/ResourceEdit.ts b/app/types/ResourceEdit.ts index 934482a..79fe672 100644 --- a/app/types/ResourceEdit.ts +++ b/app/types/ResourceEdit.ts @@ -31,3 +31,28 @@ export type ResourceEdit = ResourceEntry & { export type ResourceEditQueueRow = ResourceEdit & { current_resource: ResourceEntry & { id: number }; }; + +/** + * A row from `resource_edit_counts` — the number of PENDING edits currently + * queued against a given resource. Useful for flagging resources with + * multiple competing proposed edits before a reviewer picks one to approve. + */ +export type ResourceEditCount = { + resource_id: number; + pending_count: number; +}; + +/** + * A row from `resource_change_log` — a decided (APPROVED or REJECTED) edit, + * stripped down to just the review-lifecycle columns. One row per past + * decision, ordered by `resource_id` then most-recently-reviewed first. + */ +export type ResourceChangeLogEntry = { + edit_id: number; + resource_id: number | null; + review_status: EditReviewStatus; + submitted_by: string | null; + reviewed_by: string | null; + reviewed_at: string | null; + review_notes: string | null; +};