From 9dfb8ef0bc0343de230db981cbcaa79ecba3cd5e Mon Sep 17 00:00:00 2001 From: Cristian Istrate Date: Wed, 12 Aug 2026 12:44:35 +0300 Subject: [PATCH] votemonitor-1006 [Hacktoberfest][Web2.0] Implement Monitoring Observers page --- .../monitoring-observer-status-badge.tsx | 29 ++++ .../monitoring-observers-mutations.ts | 14 ++ .../NgoAdmin/MonitoringObservers/Page.tsx | 23 +-- .../components/Dialogs.tsx | 52 ++++++ .../components/ObserversProvider.tsx | 45 +++++ .../components/RowActions.tsx | 74 +++++++++ .../MonitoringObservers/components/Table.tsx | 54 +++--- .../components/TableColumns.tsx | 156 ++++++++++++------ .../components/TableFilters.tsx | 104 ++++++------ web2.0/src/queries/monitoring-observers.ts | 35 ++-- .../$electionRoundId/observers/index.tsx | 19 ++- .../resend-invites.api.ts | 17 ++ web2.0/src/types/monitoring-observer.ts | 46 ++++-- 13 files changed, 493 insertions(+), 175 deletions(-) create mode 100644 web2.0/src/components/badges/monitoring-observer-status-badge.tsx create mode 100644 web2.0/src/mutations/monitoring-observers-mutations.ts create mode 100644 web2.0/src/pages/NgoAdmin/MonitoringObservers/components/Dialogs.tsx create mode 100644 web2.0/src/pages/NgoAdmin/MonitoringObservers/components/ObserversProvider.tsx create mode 100644 web2.0/src/pages/NgoAdmin/MonitoringObservers/components/RowActions.tsx create mode 100644 web2.0/src/services/api/monitoring-observers/resend-invites.api.ts diff --git a/web2.0/src/components/badges/monitoring-observer-status-badge.tsx b/web2.0/src/components/badges/monitoring-observer-status-badge.tsx new file mode 100644 index 000000000..c8b3279a7 --- /dev/null +++ b/web2.0/src/components/badges/monitoring-observer-status-badge.tsx @@ -0,0 +1,29 @@ +import { MonitoringObserverStatus } from '@/types/monitoring-observer' +import { cn } from '@/lib/utils' +import { Badge } from '../ui/badge' + +/** + * The status is written out rather than run through `mapMonitoringObserverStatus`: + * that helper looks up `observers.status.*`, and those keys are not in the + * locale files yet, so it would print the key instead of a word. + */ +export default function MonitoringObserverStatusBadge({ + status, +}: { + status: MonitoringObserverStatus +}) { + return ( + + {status} + + ) +} diff --git a/web2.0/src/mutations/monitoring-observers-mutations.ts b/web2.0/src/mutations/monitoring-observers-mutations.ts new file mode 100644 index 000000000..0a25bb3fe --- /dev/null +++ b/web2.0/src/mutations/monitoring-observers-mutations.ts @@ -0,0 +1,14 @@ +import { useMutation } from '@tanstack/react-query' +import { resendMonitoringObserverInvites } from '@/services/api/monitoring-observers/resend-invites.api' + +/** + * Resending an invitation changes nothing in the list, so there is no query to + * invalidate — the only feedback is the toast the caller raises. + */ +export const useResendMonitoringObserverInvitesMutation = ( + electionRoundId: string +) => + useMutation({ + mutationFn: async (observerIds: string[]) => + await resendMonitoringObserverInvites(electionRoundId, observerIds), + }) diff --git a/web2.0/src/pages/NgoAdmin/MonitoringObservers/Page.tsx b/web2.0/src/pages/NgoAdmin/MonitoringObservers/Page.tsx index bbd07bd51..bff46d478 100644 --- a/web2.0/src/pages/NgoAdmin/MonitoringObservers/Page.tsx +++ b/web2.0/src/pages/NgoAdmin/MonitoringObservers/Page.tsx @@ -1,15 +1,16 @@ -import { useDebounce } from "@/hooks/use-debounce"; -import { Route } from "@/routes/(app)/elections/$electionRoundId/observers"; -import Table from "./components/Table"; -import { useListMonitoringObservers } from "@/queries/monitoring-observers"; +import { H1, P } from '@/components/ui/typography' +import Table from './components/Table' function Page() { - const { electionRoundId } = Route.useParams(); - const search = Route.useSearch(); - const debouncedSearch = useDebounce(search, 200); - const { data } = useListMonitoringObservers(electionRoundId, debouncedSearch); - - return ; + return ( +
+
+

Observers

+

Everyone monitoring this election round on your behalf

+
+
+ + ) } -export default Page; +export default Page diff --git a/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/Dialogs.tsx b/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/Dialogs.tsx new file mode 100644 index 000000000..b6a95f613 --- /dev/null +++ b/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/Dialogs.tsx @@ -0,0 +1,52 @@ +import { useResendMonitoringObserverInvitesMutation } from '@/mutations/monitoring-observers-mutations' +import { Route } from '@/routes/(app)/elections/$electionRoundId/observers' +import { toast } from 'sonner' +import { ConfirmDialog } from '@/components/ConfirmDialog' +import { useObservers } from './ObserversProvider' + +/** The confirmation for the row actions, mounted once beside the table. */ +export function ObserversDialogs() { + const { open, setOpen, currentRow } = useObservers() + const { electionRoundId } = Route.useParams() + + const resendMutation = + useResendMonitoringObserverInvitesMutation(electionRoundId) + + if (!currentRow) { + return null + } + + const handleResendInvite = () => { + // The endpoint takes a list because it also serves bulk resending; a single + // row passes an array of one. + resendMutation.mutate([currentRow.id], { + onSuccess: () => { + setOpen(null) + toast.success('Invitation sent') + }, + onError: () => { + toast.error('Failed to send the invitation', { + description: + 'Please try again or contact support if the problem persists.', + }) + }, + }) + } + + return ( + { + if (!isOpen) { + setOpen(null) + } + }} + handleConfirm={handleResendInvite} + isLoading={resendMutation.isPending} + className='max-w-md' + title='Resend invitation email' + desc={`A new invitation email will be sent to ${currentRow.email}.`} + confirmText='Send' + /> + ) +} diff --git a/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/ObserversProvider.tsx b/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/ObserversProvider.tsx new file mode 100644 index 000000000..dd99e1b3f --- /dev/null +++ b/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/ObserversProvider.tsx @@ -0,0 +1,45 @@ +import React, { useState } from 'react' +import type { MonitoringObserverModel } from '@/types/monitoring-observer' +import useDialogState from '@/hooks/use-dialog-state' + +type ObserversDialogType = 'resendInvite' + +type ObserversContextType = { + open: ObserversDialogType | null + setOpen: (str: ObserversDialogType | null) => void + /** The observer the confirmation dialog acts upon. */ + currentRow: MonitoringObserverModel | null + setCurrentRow: React.Dispatch< + React.SetStateAction + > +} + +const ObserversContext = React.createContext(null) + +/** + * Holds which confirmation is open and on which observer, so the row menus and + * the dialogs read the same state instead of each row owning its own copy. + */ +export function ObserversProvider({ children }: { children: React.ReactNode }) { + const [open, setOpen] = useDialogState(null) + const [currentRow, setCurrentRow] = useState( + null + ) + + return ( + + {children} + + ) +} + +// eslint-disable-next-line react-refresh/only-export-components +export const useObservers = () => { + const observersContext = React.useContext(ObserversContext) + + if (!observersContext) { + throw new Error('useObservers has to be used within ') + } + + return observersContext +} diff --git a/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/RowActions.tsx b/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/RowActions.tsx new file mode 100644 index 000000000..88c4162ee --- /dev/null +++ b/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/RowActions.tsx @@ -0,0 +1,74 @@ +import { Link } from '@tanstack/react-router' +import { useCurrentElectionRound } from '@/contexts/election-round.context' +import { Route } from '@/routes/(app)/elections/$electionRoundId/observers' +import { ElectionRoundStatus } from '@/types/election' +import { + MonitoringObserverStatus, + type MonitoringObserverModel, +} from '@/types/monitoring-observer' +import { Ellipsis } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { useObservers } from './ObserversProvider' + +type MonitoringObserverRowActionsProps = { + observer: MonitoringObserverModel +} + +export function MonitoringObserverRowActions({ + observer, +}: MonitoringObserverRowActionsProps) { + const { electionRoundId } = Route.useParams() + const { electionRound } = useCurrentElectionRound() + const { setOpen, setCurrentRow } = useObservers() + + // An archived round is frozen, so nothing about its observers can change. + const isArchived = electionRound?.status === ElectionRoundStatus.Archived + + return ( + + + + + + + + View + + + + {/* Editing an observer needs a page that does not exist yet in this app, + so the entry keeps its place in the menu but stays inert. */} + Edit + + {/* Only an observer who has not accepted yet has an invitation worth + sending again. */} + { + setCurrentRow(observer) + setOpen('resendInvite') + }} + > + Resend invitation email + + + + ) +} diff --git a/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/Table.tsx b/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/Table.tsx index fd24958a2..4d47140dd 100644 --- a/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/Table.tsx +++ b/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/Table.tsx @@ -1,50 +1,54 @@ import React from 'react' +import { useSuspenseListMonitoringObservers } from '@/queries/monitoring-observers' import { Route } from '@/routes/(app)/elections/$electionRoundId/observers' -import type { PageResponse } from '@/types/common' -import type { DataTableRowAction } from '@/types/data-table' -import type { MonitoringObserverModel } from '@/types/monitoring-observer' import { useDataTable } from '@/hooks/use-data-table' import { DataTable } from '@/components/ui/data-table' import { DataTableToolbar } from '@/components/data-table/data-table-toolbar' +import { ObserversDialogs } from './Dialogs' +import { ObserversProvider } from './ObserversProvider' import { getMonitoringObserversTableColumns } from './TableColumns' import TableFilters from './TableFilters' -export interface TableProps { - data?: PageResponse -} -function Table({ data }: TableProps) { - const [rowAction, setRowAction] = - React.useState | null>(null) - +function Table() { + const { electionRoundId } = Route.useParams() const search = Route.useSearch() const navigate = Route.useNavigate() - const columns = React.useMemo( - () => - getMonitoringObserversTableColumns({ - setRowAction, - }), - [setRowAction] - ) + + const { data } = useSuspenseListMonitoringObservers(electionRoundId, search) + + const columns = React.useMemo(() => getMonitoringObserversTableColumns(), []) const { table } = useDataTable({ tableName: 'monitoring-observers', - data: data?.items || [], + data: data.items, columns, - pageCount: data ? Math.ceil(data.totalCount / data.pageSize) : 0, + pageCount: + data.pageSize > 0 ? Math.ceil(data.totalCount / data.pageSize) : 0, initialState: { - sorting: [{ id: 'displayName', desc: false }], + // Keeps the menu reachable when the table scrolls sideways. columnPinning: { right: ['actions'] }, }, getRowId: (originalRow) => originalRow.id, + // The search params are named `pageNumber`/`pageSize`, and the hook writes + // `page` unless told otherwise. Without this the page control would put a + // key in the url that `validateSearch` drops, so paging silently did nothing. + pagination: { pageKey: 'pageNumber', pageSizeKey: 'pageSize' }, + // Searching is handled by the toolbar's own `searchText` param, not by the + // table's built-in global filter. + globalFilter: { enabled: false }, search, navigate, }) + return ( - - - - - + + + + + + + + ) } diff --git a/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/TableColumns.tsx b/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/TableColumns.tsx index ce74b1e43..0e5d83234 100644 --- a/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/TableColumns.tsx +++ b/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/TableColumns.tsx @@ -1,29 +1,16 @@ -'use client' - -import * as React from 'react' +import { format } from 'date-fns' import type { ColumnDef } from '@tanstack/react-table' -import type { DataTableRowAction } from '@/types/data-table' import type { MonitoringObserverModel } from '@/types/monitoring-observer' -import { Ellipsis } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' +import { DateTimeFormat } from '@/constants/formats' +import { Badge } from '@/components/ui/badge' +import MonitoringObserverStatusBadge from '@/components/badges/monitoring-observer-status-badge' import { DataTableColumnHeader } from '@/components/data-table/data-table-column-header' +import { MonitoringObserverRowActions } from './RowActions' -interface GetMonitoringObserversTableColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction | null> - > -} +/** Beyond this, the remaining tags collapse into a "+N" badge. */ +const VISIBLE_TAGS = 3 -export function getMonitoringObserversTableColumns({ - setRowAction, -}: GetMonitoringObserversTableColumnsProps): ColumnDef[] { +export function getMonitoringObserversTableColumns(): ColumnDef[] { return [ { id: 'displayName', @@ -34,9 +21,7 @@ export function getMonitoringObserversTableColumns({ cell: ({ row }) => (
{row.original.displayName}
), - meta: { - label: 'Name', - }, + meta: { label: 'Name' }, enableSorting: true, enableHiding: true, }, @@ -47,45 +32,108 @@ export function getMonitoringObserversTableColumns({ ), cell: ({ row }) =>
{row.original.email}
, - meta: { - label: 'Email', - }, + meta: { label: 'Email' }, enableSorting: true, enableHiding: true, }, - { - id: 'actions', - cell: function Cell({ row }) { + id: 'tags', + accessorKey: 'tags', + header: ({ column }) => ( + + ), + // Tags are an array, so there is no meaningful order to sort them by. + enableSorting: false, + enableHiding: true, + cell: ({ row }) => { + const tags = row.original.tags ?? [] + + if (tags.length === 0) { + return - + } + + const hidden = tags.length - VISIBLE_TAGS + return ( - - - - - - setRowAction({ row, variant: 'update' })} - > - Edit - +
+ {tags.slice(0, VISIBLE_TAGS).map((tag) => ( + + {tag} + + ))} + {hidden > 0 ? ( + // The full list would push every other column off screen; the + // count keeps the row honest about what is not shown. + + +{hidden} + + ) : null} +
+ ) + }, + meta: { label: 'Observer tags' }, + }, + { + id: 'phoneNumber', + accessorKey: 'phoneNumber', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.phoneNumber || ( + - + )} +
+ ), + meta: { label: 'Phone' }, + enableSorting: true, + enableHiding: true, + }, + { + id: 'status', + accessorKey: 'status', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + meta: { label: 'Observer status' }, + enableSorting: true, + enableHiding: true, + }, + { + id: 'latestActivityAt', + accessorKey: 'latestActivityAt', + header: ({ column }) => ( + + ), + size: 180, + cell: ({ row }) => { + const latestActivityAt = row.original.latestActivityAt - - setRowAction({ row, variant: 'delete' })} - > - Delete - -
-
+ // Observers who never opened the mobile app have no activity at all. + return latestActivityAt ? ( +
+ {format(latestActivityAt, DateTimeFormat)} +
+ ) : ( + Never ) }, + meta: { label: 'Latest activity at' }, + enableSorting: true, + enableHiding: true, + }, + { + header: '', + id: 'actions', + enableSorting: false, size: 40, + cell: ({ row }) => ( + + ), }, ] } diff --git a/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/TableFilters.tsx b/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/TableFilters.tsx index 189af31d0..1c8ed5094 100644 --- a/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/TableFilters.tsx +++ b/web2.0/src/pages/NgoAdmin/MonitoringObservers/components/TableFilters.tsx @@ -1,14 +1,14 @@ import React, { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' -import { getRouteApi } from '@tanstack/react-router' -import type { Table } from '@tanstack/react-table' import { listMonitoringObserversTagsQueryOptions } from '@/queries/monitoring-observers' +import { Route } from '@/routes/(app)/elections/$electionRoundId/observers' import type { Option } from '@/types/data-table' import { - MonitoringObserverStatus, - type MonitoringObserverModel, + MonitoringObserverStatusList, + type MonitoringObserverStatus, } from '@/types/monitoring-observer' import { X } from 'lucide-react' +import { useDebouncedCallback } from '@/hooks/use-debounced-callback' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { @@ -16,78 +16,81 @@ import { SingleSelectDataTableFacetedFilter, } from '@/components/data-table/data-table-faceted-filter' -interface DataTableToolbarProps extends React.ComponentProps<'div'> { - table: Table -} - -const monitoringObserverStatusOptions: Option[] = [ - { - value: MonitoringObserverStatus.Active, - label: MonitoringObserverStatus.Active, - }, - - { - value: MonitoringObserverStatus.Pending, - label: MonitoringObserverStatus.Pending, - }, - - { - value: MonitoringObserverStatus.Suspended, - label: MonitoringObserverStatus.Suspended, - }, -] +const statusOptions: Option[] = MonitoringObserverStatusList.map((status) => ({ + value: status, + label: status, +})) -const route = getRouteApi( - '/(app)/elections/$electionRoundId/observers/' as const -) - -function TableFilters({ table }: DataTableToolbarProps) { - const { electionRoundId } = route.useParams() - const search = route.useSearch() - const navigate = route.useNavigate() +function TableFilters() { + const { electionRoundId } = Route.useParams() + const search = Route.useSearch() + const navigate = Route.useNavigate() const { data: tags } = useQuery( listMonitoringObserversTagsQueryOptions(electionRoundId) ) const tagsOptions = useMemo( - () => tags?.map((t) => ({ value: t, label: t })) ?? [], + () => tags?.map((tag) => ({ value: tag, label: tag })) ?? [], [tags] ) - const isFiltered = table.getState().columnFilters.length > 0 - - // const onReset = React.useCallback(() => { - // table.resetColumnFilters(); - // }, [table]); + // Read from the url, not from the table: every filter here writes to the + // search params, so the table's own column filters are always empty and the + // reset button would never appear. + const isFiltered = + Boolean(search.searchText) || + Boolean(search.status) || + (search.tags?.length ?? 0) > 0 const onReset = React.useCallback(() => { - console.log('reset') - }, []) + navigate({ + search: { pageNumber: 1, pageSize: search.pageSize }, + replace: true, + }) + }, [navigate, search.pageSize]) + + // The input keeps its own value so typing stays instant, while the url — and + // with it the request — is only rewritten once the user pauses. + const [searchInput, setSearchInput] = React.useState(search.searchText ?? '') + + React.useEffect(() => { + setSearchInput(search.searchText ?? '') + }, [search.searchText]) + + const debouncedSearch = useDebouncedCallback((value: string) => { + navigate({ + // Back to the first page: the current one may not exist once the list + // shrinks. + search: (prev) => ({ ...prev, searchText: value, pageNumber: 1 }), + replace: true, + }) + }, 500) + + const handleInputChange = (event: React.ChangeEvent) => { + setSearchInput(event.target.value) + debouncedSearch(event.target.value) + } return (
- navigate({ - search: (prev) => ({ ...prev, searchText: event.target.value }), - replace: true, - }) - } + value={searchInput} + onChange={handleInputChange} className='h-8 w-40 lg:w-56' /> navigate({ search: (prev) => ({ ...prev, status: value as MonitoringObserverStatus, + pageNumber: 1, }), replace: true, }) @@ -100,10 +103,7 @@ function TableFilters({ table }: DataTableToolbarProps) { value={search.tags} onValueChange={(value) => navigate({ - search: (prev) => ({ - ...prev, - tags: value, - }), + search: (prev) => ({ ...prev, tags: value, pageNumber: 1 }), replace: true, }) } diff --git a/web2.0/src/queries/monitoring-observers.ts b/web2.0/src/queries/monitoring-observers.ts index 72beabe62..57c7b91ce 100644 --- a/web2.0/src/queries/monitoring-observers.ts +++ b/web2.0/src/queries/monitoring-observers.ts @@ -1,24 +1,24 @@ -import { listMonitoringObserversTags } from "@/services/api/monitoring-observers/list-tags.api"; -import { listMonitoringObservers } from "@/services/api/monitoring-observers/list.api"; -import type { MonitoringObserversSearch } from "@/types/monitoring-observer"; -import { queryOptions, useQuery } from "@tanstack/react-query"; +import { queryOptions, useQuery, useSuspenseQuery } from '@tanstack/react-query' +import { listMonitoringObserversTags } from '@/services/api/monitoring-observers/list-tags.api' +import { listMonitoringObservers } from '@/services/api/monitoring-observers/list.api' +import type { MonitoringObserversSearch } from '@/types/monitoring-observer' export const monitoringObserversKeys = { all: (electionRoundId: string) => - ["monitoring-observers", electionRoundId] as const, + ['monitoring-observers', electionRoundId] as const, lists: (electionRoundId: string) => - [...monitoringObserversKeys.all(electionRoundId), "list"] as const, + [...monitoringObserversKeys.all(electionRoundId), 'list'] as const, list: (electionRoundId: string, search: MonitoringObserversSearch) => [...monitoringObserversKeys.lists(electionRoundId), { ...search }] as const, details: (electionRoundId: string) => - [...monitoringObserversKeys.all(electionRoundId), "detail"] as const, + [...monitoringObserversKeys.all(electionRoundId), 'detail'] as const, detail: (electionRoundId: string, id: string) => [...monitoringObserversKeys.details(electionRoundId), id] as const, tags: (electionRoundId: string) => - [...monitoringObserversKeys.details(electionRoundId), "tags"] as const, -}; + [...monitoringObserversKeys.details(electionRoundId), 'tags'] as const, +} -const STALE_TIME = 1000 * 60 * 15; // 15 minutes +const STALE_TIME = 1000 * 60 * 15 // 15 minutes export const listMonitoringObserversQueryOptions = ( electionRoundId: string, @@ -30,12 +30,17 @@ export const listMonitoringObserversQueryOptions = ( enabled: !!electionRoundId, staleTime: STALE_TIME, refetchOnWindowFocus: false, - }); + }) -export const useListMonitoringObservers = ( +/** + * Used by the table, which the route loader has already primed, so the rows are + * on screen from the first paint instead of flashing an empty shell. + */ +export const useSuspenseListMonitoringObservers = ( electionRoundId: string, search: MonitoringObserversSearch -) => useQuery(listMonitoringObserversQueryOptions(electionRoundId, search)); +) => + useSuspenseQuery(listMonitoringObserversQueryOptions(electionRoundId, search)) export const listMonitoringObserversTagsQueryOptions = ( electionRoundId: string @@ -45,7 +50,7 @@ export const listMonitoringObserversTagsQueryOptions = ( queryFn: async () => await listMonitoringObserversTags(electionRoundId), enabled: !!electionRoundId, staleTime: STALE_TIME, - }); + }) export const useListMonitoringObserversTags = (electionRoundId: string) => - useQuery(listMonitoringObserversTagsQueryOptions(electionRoundId)); + useQuery(listMonitoringObserversTagsQueryOptions(electionRoundId)) diff --git a/web2.0/src/routes/(app)/elections/$electionRoundId/observers/index.tsx b/web2.0/src/routes/(app)/elections/$electionRoundId/observers/index.tsx index d065a9914..8d25f3db2 100644 --- a/web2.0/src/routes/(app)/elections/$electionRoundId/observers/index.tsx +++ b/web2.0/src/routes/(app)/elections/$electionRoundId/observers/index.tsx @@ -1,10 +1,27 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, stripSearchParams } from '@tanstack/react-router' import Page from '@/pages/NgoAdmin/MonitoringObservers/Page' +import { listMonitoringObserversQueryOptions } from '@/queries/monitoring-observers' import { monitoringObserversSearchSchema } from '@/types/monitoring-observer' export const Route = createFileRoute( '/(app)/elections/$electionRoundId/observers/' )({ validateSearch: monitoringObserversSearchSchema, + search: { + // Keep the url readable while nothing is filtered. + middlewares: [ + stripSearchParams({ + searchText: undefined, + status: undefined, + tags: undefined, + }), + ], + }, + loaderDeps: ({ search }) => ({ ...search }), + loader: async ({ context, deps, params: { electionRoundId } }) => { + await context.queryClient.ensureQueryData( + listMonitoringObserversQueryOptions(electionRoundId, deps) + ) + }, component: Page, }) diff --git a/web2.0/src/services/api/monitoring-observers/resend-invites.api.ts b/web2.0/src/services/api/monitoring-observers/resend-invites.api.ts new file mode 100644 index 000000000..b9acc9070 --- /dev/null +++ b/web2.0/src/services/api/monitoring-observers/resend-invites.api.ts @@ -0,0 +1,17 @@ +import API from '@/services/api' + +/** + * Sends the invitation email again. + * + * The endpoint takes a list because it also serves bulk resending from the + * observers table; a single row passes an array of one. + */ +export const resendMonitoringObserverInvites = ( + electionRoundId: string, + observerIds: string[] +): Promise => { + return API.put( + `/election-rounds/${electionRoundId}/monitoring-observers:resend-invites`, + { ids: observerIds } + ).then(() => undefined) +} diff --git a/web2.0/src/types/monitoring-observer.ts b/web2.0/src/types/monitoring-observer.ts index 3b28e4c77..daf4bfe7d 100644 --- a/web2.0/src/types/monitoring-observer.ts +++ b/web2.0/src/types/monitoring-observer.ts @@ -1,25 +1,37 @@ -import { z } from "zod"; -import { SortOrder } from "./common"; +import { z } from 'zod' +import { SortOrder } from './common' export enum MonitoringObserverStatus { - Active = "Active", - Pending = "Pending", - Suspended = "Suspended", + Active = 'Active', + Pending = 'Pending', + Suspended = 'Suspended', } +/** Fixed order, so the status filter never reshuffles between renders. */ +export const MonitoringObserverStatusList: MonitoringObserverStatus[] = [ + MonitoringObserverStatus.Active, + MonitoringObserverStatus.Pending, + MonitoringObserverStatus.Suspended, +] + export interface MonitoringObserverModel { - id: string; - firstName: string; - lastName: string; - displayName: string; - email: string; - status: MonitoringObserverStatus; - phoneNumber: string; - tags: string[]; - isOwnObserver: boolean; - latestActivityAt?: string; + id: string + firstName: string + lastName: string + displayName: string + email: string + status: MonitoringObserverStatus + phoneNumber: string + tags: string[] + isOwnObserver: boolean + /** Absent for observers who have never used the mobile app. */ + latestActivityAt?: string } +/** + * Everything the list is filtered, sorted and paged by lives here, which is + * what keeps the whole view shareable and reloadable from its url alone. + */ export const monitoringObserversSearchSchema = z.object({ tags: z.array(z.string()).optional(), searchText: z.string().optional(), @@ -28,8 +40,8 @@ export const monitoringObserversSearchSchema = z.object({ sortOrder: z.enum(SortOrder).optional(), pageNumber: z.number().default(1), pageSize: z.number().default(25), -}); +}) export type MonitoringObserversSearch = z.infer< typeof monitoringObserversSearchSchema ->; +>