Skip to content

Commit 90bb60e

Browse files
authored
[PlatformAdmin] Add monitoring NGOs screen (#917)
* fix: make dropdown menus scrollable * fix: truncate overflowing table columns * Squashed commit of the following: commit 742f250 Author: imdeaconu <imdeaconu@gmail.com> Date: Wed Sep 11 19:54:55 2024 +0300 add read notification checkmark commit ea11fa0 Author: imdeaconu <imdeaconu@gmail.com> Date: Wed Sep 11 19:54:30 2024 +0300 add read notification column * Squashed commit of the following: commit d8833dc Author: imdeaconu <imdeaconu@gmail.com> Date: Fri Sep 13 13:29:31 2024 +0300 WIP: add selector functionality commit 3608c0e Author: imdeaconu <imdeaconu@gmail.com> Date: Fri Sep 13 10:00:05 2024 +0300 WIP: create new tags input * chore: remove unused import * chore: delete duplicated / unused classes * feature: add searching to MonitoringObserversTagFilter * chore: update config files * Revert "[NGO Admin] Rewrite the tag selector component (#675)" This reverts commit 2ad0e90. * Merge branch 'main' of https://github.com/commitglobal/votemonitor into commitglobal-main * WIP: rename prop for alternative filter key in Observer Tags and add it to the Push Message form * WIP: fix push messages receipients query not invalidating after edits * invalidate targeted observers query after a morning observer is added * WIPȘ add monitoring ngos table * WIP: add modal for assigning a new monitoring NGO * WIP: fix bugs
1 parent 404f417 commit 90bb60e

File tree

4 files changed

+360
-12
lines changed

4 files changed

+360
-12
lines changed
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { authApi } from '@/common/auth-api';
2+
import { Button } from '@/components/ui/button';
3+
import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumnHeader';
4+
import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable';
5+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
6+
import { Input } from '@/components/ui/input';
7+
import { toast } from '@/components/ui/use-toast';
8+
import { useMutation, useQueryClient } from '@tanstack/react-query';
9+
import { ColumnDef } from '@tanstack/react-table';
10+
import { useDebounce } from '@uidotdev/usehooks';
11+
import { Plus } from 'lucide-react';
12+
import { ChangeEvent, useMemo, useState } from 'react';
13+
import { MonitoringNgoModel } from '../../models/types';
14+
import { monitoringNgoKeys, useAvailableMonitoringNgos } from './queries';
15+
16+
export interface AddMonitoringNgoDialogProps {
17+
electionRoundId: string;
18+
open: boolean;
19+
onOpenChange: (open: boolean) => void;
20+
}
21+
22+
const useMonitoringNgoSearch = () => {
23+
const [searchText, setSearchText] = useState('');
24+
const debouncedSearchText = useDebounce(searchText, 300);
25+
26+
const handleSearchInput = (ev: ChangeEvent<HTMLInputElement>): void => {
27+
setSearchText(ev.currentTarget.value);
28+
};
29+
30+
const queryParams = useMemo(() => {
31+
const params = [['searchText', debouncedSearchText]].filter(([_, value]) => value);
32+
33+
return Object.fromEntries(params);
34+
}, [debouncedSearchText]);
35+
36+
return { searchText, queryParams, handleSearchInput };
37+
};
38+
39+
function AddMonitoringNgoDialog({ open, onOpenChange, electionRoundId }: AddMonitoringNgoDialogProps) {
40+
const { searchText, handleSearchInput, queryParams } = useMonitoringNgoSearch();
41+
const queryClient = useQueryClient();
42+
const addMonitoringNgoMutation = useMutation({
43+
mutationFn: async (ngoId: string) => {
44+
return await authApi.post(`election-rounds/${electionRoundId}/monitoring-ngos`, { ngoId });
45+
},
46+
47+
onSuccess: () => {
48+
queryClient.invalidateQueries({ queryKey: monitoringNgoKeys.all(electionRoundId) });
49+
onOpenChange(false);
50+
toast({
51+
title: 'Success',
52+
description: 'Added monitoring NGO',
53+
});
54+
},
55+
//TODO Add error handling
56+
});
57+
58+
const monitoringNgosColDefs: ColumnDef<MonitoringNgoModel>[] = [
59+
{
60+
accessorKey: 'name',
61+
enableSorting: true,
62+
header: ({ column }) => <DataTableColumnHeader title='Name' column={column} />,
63+
},
64+
65+
{
66+
id: 'actions',
67+
cell: ({ row }) => {
68+
const ngoId = row.original.id;
69+
70+
return (
71+
<Button
72+
title='Add'
73+
onClick={() => {
74+
addMonitoringNgoMutation.mutate(ngoId);
75+
}}>
76+
<Plus className='mr-2' width={18} height={18} />
77+
Add
78+
</Button>
79+
);
80+
},
81+
},
82+
];
83+
84+
return (
85+
<Dialog open={open} onOpenChange={onOpenChange} modal={true}>
86+
<DialogContent
87+
className='min-w-[450px] max-h-[950px]'
88+
onInteractOutside={(e) => {
89+
e.preventDefault();
90+
}}
91+
onEscapeKeyDown={(e) => {
92+
e.preventDefault();
93+
}}>
94+
<DialogHeader>
95+
<DialogTitle className='mb-3.5'>Add monitoring NGO</DialogTitle>
96+
<Input value={searchText} onChange={handleSearchInput} className='max-w-md' placeholder='Search' />
97+
</DialogHeader>
98+
<div className='flex flex-col gap-3 overflow-auto h-[650px]'>
99+
<QueryParamsDataTable
100+
columns={monitoringNgosColDefs}
101+
useQuery={(params) => useAvailableMonitoringNgos(electionRoundId, params)}
102+
queryParams={queryParams}
103+
/>
104+
</div>
105+
</DialogContent>
106+
</Dialog>
107+
);
108+
}
109+
110+
export default AddMonitoringNgoDialog;

web/src/features/election-rounds/components/MonitoringNgosDashboard/MonitoringNgosDashboard.tsx

Lines changed: 180 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,194 @@
1+
import { authApi } from '@/common/auth-api';
2+
import type { FunctionComponent } from '@/common/types';
3+
import { useConfirm } from '@/components/ui/alert-dialog-provider';
4+
import { Button, buttonVariants } from '@/components/ui/button';
15
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
6+
import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumnHeader';
7+
import {
8+
DropdownMenu,
9+
DropdownMenuContent,
10+
DropdownMenuItem,
11+
DropdownMenuTrigger,
12+
} from '@/components/ui/dropdown-menu';
213
import { Separator } from '@/components/ui/separator';
14+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
15+
import { useDialog } from '@/components/ui/use-dialog';
16+
import { toast } from '@/components/ui/use-toast';
17+
import { NgoStatusBadge } from '@/features/ngos/components/NgoStatusBadges';
18+
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
19+
import { useMutation, useQueryClient } from '@tanstack/react-query';
20+
import type { ColumnDef } from '@tanstack/react-table';
21+
import { flexRender, getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table';
22+
import { Plus } from 'lucide-react';
323
import { useTranslation } from 'react-i18next';
24+
import { MonitoringNgoModel } from '../../models/types';
25+
import AddMonitoringNgoDialog from './AddMonitoringNgoDialog';
26+
import { monitoringNgoKeys, useMonitoringNgos } from './queries';
27+
28+
type MonitoringNgosTableProps = {
29+
columns: ColumnDef<MonitoringNgoModel>[];
30+
data: MonitoringNgoModel[];
31+
};
32+
33+
function MonitoringNgosTable({ columns, data }: MonitoringNgosTableProps): FunctionComponent {
34+
const table = useReactTable({
35+
columns,
36+
data,
37+
getCoreRowModel: getCoreRowModel(),
38+
getSortedRowModel: getSortedRowModel(),
39+
});
40+
41+
const rows = table.getRowModel().rows;
42+
43+
return (
44+
<>
45+
<Table>
46+
<TableHeader>
47+
{table.getHeaderGroups().map((headerGroup) => (
48+
<TableRow key={headerGroup.id}>
49+
{headerGroup.headers.map((header) => {
50+
return (
51+
<TableHead key={header.id} style={{ width: header.getSize() }}>
52+
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
53+
</TableHead>
54+
);
55+
})}
56+
</TableRow>
57+
))}
58+
</TableHeader>
59+
60+
<TableBody>
61+
{rows.length > 0 ? (
62+
table.getRowModel().rows.map((row) => (
63+
<TableRow key={row.id}>
64+
{row.getVisibleCells().map((cell) => (
65+
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
66+
))}
67+
</TableRow>
68+
))
69+
) : (
70+
<TableRow>
71+
<TableCell className='h-24 text-center' colSpan={columns.length}>
72+
No results.
73+
</TableCell>
74+
</TableRow>
75+
)}
76+
</TableBody>
77+
</Table>
78+
</>
79+
);
80+
}
481

582
export interface MonitoringNgosDashboardProps {
683
electionRoundId: string;
784
}
885
function MonitoringNgosDashboard({ electionRoundId }: MonitoringNgosDashboardProps) {
986
const { t } = useTranslation();
87+
const addMonitoringNgoDialog = useDialog();
88+
const { data } = useMonitoringNgos(electionRoundId);
89+
const queryClient = useQueryClient();
90+
const confirm = useConfirm();
91+
const deleteMonitoringNgoMutation = useMutation({
92+
mutationFn: async (ngoId: string) => {
93+
return await authApi.delete(`election-rounds/${electionRoundId}/monitoring-ngos/${ngoId}`);
94+
},
95+
96+
onSuccess: () => {
97+
queryClient.invalidateQueries({ queryKey: monitoringNgoKeys.all(electionRoundId) });
98+
toast({
99+
title: 'Success',
100+
description: 'Removed monitoring NGO',
101+
});
102+
},
103+
//TODO Add error handling
104+
});
105+
106+
const monitoringNgosColDefs: ColumnDef<MonitoringNgoModel>[] = [
107+
{
108+
accessorKey: 'name',
109+
enableSorting: true,
110+
header: ({ column }) => <DataTableColumnHeader title='Name' column={column} />,
111+
},
112+
113+
{
114+
accessorKey: 'ngoStatus',
115+
enableSorting: false,
116+
header: ({ column }) => <DataTableColumnHeader title='Status' column={column} />,
117+
cell: ({
118+
row: {
119+
original: { ngoStatus },
120+
},
121+
}) => {
122+
return <NgoStatusBadge status={ngoStatus} />;
123+
},
124+
},
125+
{
126+
id: 'actions',
127+
cell: ({ row }) => {
128+
const ngoId = row.original.ngoId;
129+
130+
return (
131+
<div className='text-right'>
132+
<DropdownMenu>
133+
<DropdownMenuTrigger asChild>
134+
<Button variant='ghost-primary' size='icon'>
135+
<span className='sr-only'>Actions</span>
136+
<EllipsisVerticalIcon className='w-6 h-6' />
137+
</Button>
138+
</DropdownMenuTrigger>
139+
<DropdownMenuContent align='end'>
140+
<DropdownMenuItem
141+
className='text-red-600'
142+
onClick={async (e) => {
143+
e.stopPropagation();
144+
145+
if (
146+
await confirm({
147+
title: `Delete ${row.original.name}?`,
148+
body: 'This action is permanent and cannot be undone. Once deleted, this NGO admin cannot be retrieved.',
149+
actionButton: 'Delete',
150+
actionButtonClass: buttonVariants({ variant: 'destructive' }),
151+
cancelButton: 'Cancel',
152+
})
153+
) {
154+
deleteMonitoringNgoMutation.mutate(ngoId);
155+
}
156+
}}>
157+
Delete
158+
</DropdownMenuItem>
159+
</DropdownMenuContent>
160+
</DropdownMenu>
161+
</div>
162+
);
163+
},
164+
},
165+
];
10166

11167
return (
12-
<Card>
13-
<CardHeader className='flex gap-2 flex-column'>
14-
<div className='flex flex-row items-center justify-between'>
15-
<CardTitle className='text-2xl font-semibold leading-none tracking-tight'>
16-
{t('electionEvent.monitoringNgos.cardTitle')}
17-
</CardTitle>
18-
</div>
19-
<Separator />
20-
</CardHeader>
21-
<CardContent className='flex flex-col items-baseline gap-6'>{electionRoundId}</CardContent>
22-
</Card>
168+
<>
169+
<Card>
170+
<CardHeader className='flex gap-2 flex-column'>
171+
<div className='flex flex-row items-center justify-between'>
172+
<CardTitle className='text-2xl font-semibold leading-none tracking-tight'>
173+
{t('electionEvent.monitoringNgos.cardTitle')}
174+
</CardTitle>
175+
<div className='flex md:flex-row-reverse gap-4 table-actions'>
176+
<Button title='Add admin' onClick={() => addMonitoringNgoDialog.trigger()}>
177+
<Plus className='mr-2' width={18} height={18} />
178+
Add monitoring NGO
179+
</Button>
180+
</div>
181+
</div>
182+
<Separator />
183+
</CardHeader>
184+
<CardContent className='flex flex-col items-baseline gap-6'>
185+
<MonitoringNgosTable columns={monitoringNgosColDefs} data={data?.monitoringNgos ?? []} />
186+
</CardContent>
187+
</Card>
188+
{addMonitoringNgoDialog.dialogProps.open && (
189+
<AddMonitoringNgoDialog electionRoundId={electionRoundId} {...addMonitoringNgoDialog.dialogProps} />
190+
)}
191+
</>
23192
);
24193
}
25194

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { authApi } from '@/common/auth-api';
2+
import { DataTableParameters, ElectionRoundStatus, PageResponse } from '@/common/types';
3+
import { UseQueryResult, useQuery } from '@tanstack/react-query';
4+
import { MonitoringNgoModel } from '../../models/types';
5+
const STALE_TIME = 1000 * 60 * 15; // fifteen minutes
6+
7+
export interface ElectionsRoundsQueryParams {
8+
searchText: string | undefined;
9+
countryId: string | undefined;
10+
electionRoundStatus: ElectionRoundStatus | undefined;
11+
}
12+
13+
export const monitoringNgoKeys = {
14+
all: (electionRoundId: string) => ['monitoringNgos', electionRoundId] as const,
15+
availableForMonitoring: (electionRoundId: string, params: DataTableParameters) =>
16+
[...monitoringNgoKeys.all(electionRoundId), 'available', { ...params }] as const,
17+
};
18+
19+
type MonitoringNgosPageResponse = {
20+
monitoringNgos: MonitoringNgoModel[];
21+
};
22+
23+
export function useMonitoringNgos(electionRoundId: string): UseQueryResult<MonitoringNgosPageResponse, Error> {
24+
return useQuery({
25+
queryKey: monitoringNgoKeys.all(electionRoundId),
26+
placeholderData: { monitoringNgos: [] },
27+
queryFn: async () => {
28+
const response = await authApi.get<MonitoringNgosPageResponse>(
29+
`election-rounds/${electionRoundId}/monitoring-ngos`
30+
);
31+
32+
if (response.status !== 200) {
33+
throw new Error('Failed to fetch monitoring NGOs for election round');
34+
}
35+
36+
return response.data;
37+
},
38+
enabled: !!electionRoundId,
39+
40+
staleTime: STALE_TIME,
41+
});
42+
}
43+
44+
export function useAvailableMonitoringNgos(
45+
electionRoundId: string,
46+
p: DataTableParameters
47+
): UseQueryResult<PageResponse<MonitoringNgoModel>, Error> {
48+
return useQuery({
49+
queryKey: monitoringNgoKeys.availableForMonitoring(electionRoundId, p),
50+
queryFn: async () => {
51+
const response = await authApi.get<PageResponse<MonitoringNgoModel>>(
52+
`election-rounds/${electionRoundId}/monitoring-ngos:available`,
53+
{
54+
params: {
55+
...p.otherParams,
56+
},
57+
}
58+
);
59+
60+
if (response.status !== 200) {
61+
throw new Error('Failed to fetch ngo admins');
62+
}
63+
64+
return response.data;
65+
},
66+
staleTime: STALE_TIME,
67+
});
68+
}

web/src/features/election-rounds/models/types.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { NGOStatus } from '@/features/ngos/models/NGO';
44
export interface MonitoringNgoModel {
55
id: string;
66
name: string;
7-
status: NGOStatus;
7+
ngoId: string;
8+
ngoStatus: NGOStatus;
89
}
910

1011
export interface ElectionRoundModel {

0 commit comments

Comments
 (0)