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 }) => (
+ {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}
+