diff --git a/api/src/Vote.Monitor.Api.Feature.Ngo/Create/Endpoint.cs b/api/src/Vote.Monitor.Api.Feature.Ngo/Create/Endpoint.cs index 4f8de2845..02e86ba44 100644 --- a/api/src/Vote.Monitor.Api.Feature.Ngo/Create/Endpoint.cs +++ b/api/src/Vote.Monitor.Api.Feature.Ngo/Create/Endpoint.cs @@ -4,7 +4,7 @@ namespace Vote.Monitor.Api.Feature.Ngo.Create; public class Endpoint(IRepository repository) : - Endpoint, Conflict>> + Endpoint, ProblemDetails>> { public override void Configure() { @@ -14,7 +14,7 @@ public override void Configure() Policies(PolicyNames.PlatformAdminsOnly); } - public override async Task, Conflict>> ExecuteAsync(Request req, + public override async Task, ProblemDetails>> ExecuteAsync(Request req, CancellationToken ct) { var specification = new GetNgoByNameSpecification(req.Name); @@ -23,7 +23,7 @@ public override async Task, Conflict>> Exec if (hasNgoWithSameName) { AddError(r => r.Name, "A Ngo with same name already exists"); - return TypedResults.Conflict(new ProblemDetails(ValidationFailures)); + return new ProblemDetails(ValidationFailures); } var ngo = new NgoAggregate(req.Name); diff --git a/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Create/Endpoint.cs b/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Create/Endpoint.cs index cb254adb4..7416997f9 100644 --- a/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Create/Endpoint.cs +++ b/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Create/Endpoint.cs @@ -7,7 +7,7 @@ namespace Vote.Monitor.Api.Feature.NgoAdmin.Create; public class Endpoint( UserManager userManager, IRepository repository) - : Endpoint, Conflict>> + : Endpoint, ProblemDetails>> { public override void Configure() { @@ -18,14 +18,14 @@ public override void Configure() Policies(PolicyNames.PlatformAdminsOnly); } - public override async Task, Conflict>> ExecuteAsync(Request req, + public override async Task, ProblemDetails>> ExecuteAsync(Request req, CancellationToken ct) { var user = await userManager.FindByEmailAsync(req.Email); if (user is not null) { - AddError(r => r.Email, "A ngo admin with same login already exists"); - return TypedResults.Conflict(new ProblemDetails(ValidationFailures)); + AddError(r => r.Email, "A user with same login already exists"); + return new ProblemDetails(ValidationFailures); } var applicationUser = @@ -35,7 +35,7 @@ public override async Task, Conflict>> if (!result.Succeeded) { AddError(r => r.Email, result.GetAllErrors()); - return TypedResults.Conflict(new ProblemDetails(ValidationFailures)); + return new ProblemDetails(ValidationFailures); } var ngoAdmin = new NgoAdminAggregate(req.NgoId, applicationUser); diff --git a/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Update/Endpoint.cs b/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Update/Endpoint.cs index bab8bb65d..65b27d518 100644 --- a/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Update/Endpoint.cs +++ b/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Update/Endpoint.cs @@ -29,7 +29,6 @@ public override async Task> Exec } ngoAdmin.ApplicationUser.UpdateDetails(req.FirstName, req.LastName, req.PhoneNumber); - ngoAdmin.ApplicationUser.UpdateStatus(req.Status); var result = await userManager.UpdateAsync(ngoAdmin.ApplicationUser); if (!result.Succeeded) diff --git a/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Update/Request.cs b/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Update/Request.cs index 43adb0a9b..4d6e67682 100644 --- a/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Update/Request.cs +++ b/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Update/Request.cs @@ -7,6 +7,4 @@ public class Request public string FirstName { get; set; } public string LastName { get; set; } public string? PhoneNumber { get; set; } - - public UserStatus Status { get; set; } } diff --git a/api/tests/Vote.Monitor.Api.Feature.Ngo.UnitTests/Endpoints/CreateEndpointTests.cs b/api/tests/Vote.Monitor.Api.Feature.Ngo.UnitTests/Endpoints/CreateEndpointTests.cs index fc48d8059..373ebfcb6 100644 --- a/api/tests/Vote.Monitor.Api.Feature.Ngo.UnitTests/Endpoints/CreateEndpointTests.cs +++ b/api/tests/Vote.Monitor.Api.Feature.Ngo.UnitTests/Endpoints/CreateEndpointTests.cs @@ -29,7 +29,7 @@ await repository .AddAsync(Arg.Is(x => x.Name == ngoName)); result - .Should().BeOfType, Conflict>>()! + .Should().BeOfType, ProblemDetails>>()! .Which! .Result.Should().BeOfType>()! .Which!.Value!.Name.Should().Be(ngoName); @@ -53,8 +53,8 @@ public async Task ShouldReturnConflict_WhenNgoWithSameNameExists() // Assert result - .Should().BeOfType, Conflict>>() + .Should().BeOfType, ProblemDetails>>() .Which - .Result.Should().BeOfType>(); + .Result.Should().BeOfType(); } } diff --git a/web/src/common/types.ts b/web/src/common/types.ts index 2378a3989..2c1f0490b 100644 --- a/web/src/common/types.ts +++ b/web/src/common/types.ts @@ -410,3 +410,12 @@ export interface FormBase { numberOfQuestions: number; languagesTranslationStatus: LanguagesTranslationStatus; } + +export interface ProblemDetails { + type: string; + title: string; + status: number; + detail: string; + instance?: string; + errors?: { name: string; reason: string }[]; // Maps field names to error messages +} diff --git a/web/src/common/zod-schemas.ts b/web/src/common/zod-schemas.ts new file mode 100644 index 000000000..4faa59e0e --- /dev/null +++ b/web/src/common/zod-schemas.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import { SortOrder } from './types'; + +export const PageParametersBaseSchema = z.object({ + pageNumber: z.number().catch(1), + pageSize: z.number().catch(10), +}); + +export const SortParamsBaseSchema = z.object({ + sortColumnName: z.string().catch(''), + searchText: z.coerce.string().optional(), + sortOrder: z.nativeEnum(SortOrder).catch(SortOrder.asc), +}); + +export const DefaultSearchParamsSchema = PageParametersBaseSchema.merge(SortParamsBaseSchema); diff --git a/web/src/components/layout/Header/Header.tsx b/web/src/components/layout/Header/Header.tsx index ec1673598..318a9e6c0 100644 --- a/web/src/components/layout/Header/Header.tsx +++ b/web/src/components/layout/Header/Header.tsx @@ -47,13 +47,10 @@ const Header = (): FunctionComponent => { const navigate = useNavigate(); const [selectedElectionRound, setSelectedElection] = useState(); const router = useRouter(); - const { - setCurrentElectionRoundId, - currentElectionRoundId, - } = useCurrentElectionRoundStore((s) => s); + const { setCurrentElectionRoundId, currentElectionRoundId } = useCurrentElectionRoundStore((s) => s); const handleSelectElectionRound = async (electionRound?: ElectionEvent): Promise => { - if (electionRound && selectedElectionRound?.id != electionRound.id ) { + if (electionRound && selectedElectionRound?.id != electionRound.id) { setSelectedElection(electionRound); setCurrentElectionRoundId(electionRound.id); @@ -149,7 +146,9 @@ const Header = (): FunctionComponent => {
- {userRole !== 'NgoAdmin'? <> : status === 'pending' ? ( + {userRole !== 'NgoAdmin' ? ( + <> + ) : status === 'pending' ? ( ) : ( @@ -173,9 +172,7 @@ const Header = (): FunctionComponent => { Upcomming elections {activeElections?.map((electionRound) => ( - +
{electionRound?.status === ElectionRoundStatus.NotStarted ? ( @@ -192,9 +189,7 @@ const Header = (): FunctionComponent => { Archived elections {archivedElections?.map((electionRound) => ( - +
diff --git a/web/src/features/CitizenNotifications/CitizenNotificationMessageForm/CitizenNotificationMessageForm.tsx b/web/src/features/CitizenNotifications/CitizenNotificationMessageForm/CitizenNotificationMessageForm.tsx index 4334fdc94..864a6b1de 100644 --- a/web/src/features/CitizenNotifications/CitizenNotificationMessageForm/CitizenNotificationMessageForm.tsx +++ b/web/src/features/CitizenNotifications/CitizenNotificationMessageForm/CitizenNotificationMessageForm.tsx @@ -36,6 +36,7 @@ function CitizenNotificationMessageForm(): FunctionComponent { const form = useForm>({ resolver: zodResolver(createPushMessageSchema), + mode: 'all', defaultValues: { title: '', messageBody: '', diff --git a/web/src/features/auth/AcceptInvite.tsx b/web/src/features/auth/AcceptInvite.tsx index e22c9a801..ae69956a0 100644 --- a/web/src/features/auth/AcceptInvite.tsx +++ b/web/src/features/auth/AcceptInvite.tsx @@ -39,6 +39,7 @@ function AcceptInvite() { const form = useForm>({ resolver: zodResolver(formSchema), + mode: 'all', defaultValues: { password: '', confirmPassword: '', diff --git a/web/src/features/auth/ForgotPassword.tsx b/web/src/features/auth/ForgotPassword.tsx index bce38f61b..ca723d84f 100644 --- a/web/src/features/auth/ForgotPassword.tsx +++ b/web/src/features/auth/ForgotPassword.tsx @@ -27,6 +27,7 @@ interface ForgotPasswordRequest { function ForgotPassword() { const form = useForm>({ resolver: zodResolver(formSchema), + mode: 'all', defaultValues: { email: '', }, diff --git a/web/src/features/auth/Login.tsx b/web/src/features/auth/Login.tsx index 90aec543b..b0e79b9ed 100644 --- a/web/src/features/auth/Login.tsx +++ b/web/src/features/auth/Login.tsx @@ -27,6 +27,7 @@ function Login() { const navigate = useNavigate(); const form = useForm>({ resolver: zodResolver(formSchema), + mode: 'all', defaultValues: { email: '', password: '', diff --git a/web/src/features/auth/ResetPassword.tsx b/web/src/features/auth/ResetPassword.tsx index 4f33f7f19..b580115b3 100644 --- a/web/src/features/auth/ResetPassword.tsx +++ b/web/src/features/auth/ResetPassword.tsx @@ -48,6 +48,7 @@ function ResetPassword(): FunctionComponent { const form = useForm>({ resolver: zodResolver(formSchema), + mode: 'all', defaultValues: { email: '', password: '', diff --git a/web/src/features/election-event/components/Guides/AddGuideForm.tsx b/web/src/features/election-event/components/Guides/AddGuideForm.tsx index 3486a40ab..2067a2bfb 100644 --- a/web/src/features/election-event/components/Guides/AddGuideForm.tsx +++ b/web/src/features/election-event/components/Guides/AddGuideForm.tsx @@ -83,6 +83,7 @@ export default function AddGuideForm({ const form = useForm({ resolver: zodResolver(newGuideFormSchema), + mode: 'all', defaultValues: { guideType: guideType, title: '', diff --git a/web/src/features/election-event/components/Guides/EditGuideForm.tsx b/web/src/features/election-event/components/Guides/EditGuideForm.tsx index 20db2e819..4ea102932 100644 --- a/web/src/features/election-event/components/Guides/EditGuideForm.tsx +++ b/web/src/features/election-event/components/Guides/EditGuideForm.tsx @@ -94,6 +94,7 @@ export default function EditGuideForm({ type EditGuideType = z.infer; const form = useForm({ resolver: zodResolver(editGuideFormSchema), + mode: 'all', defaultValues: { guidePageType: guidePageType, guideType: guideType, diff --git a/web/src/features/election-rounds/components/ElectionRoundForm/ElectionRoundForm.tsx b/web/src/features/election-rounds/components/ElectionRoundForm/ElectionRoundForm.tsx index 7c7830d71..4d113eda1 100644 --- a/web/src/features/election-rounds/components/ElectionRoundForm/ElectionRoundForm.tsx +++ b/web/src/features/election-rounds/components/ElectionRoundForm/ElectionRoundForm.tsx @@ -52,6 +52,7 @@ function ElectionRoundForm({ electionRound, children, onSubmit }: ElectionRoundF const form = useForm({ resolver: zodResolver(electionRoundSchema), + mode: 'all', defaultValues: { countryId: electionRound?.countryId ?? '', title: electionRound?.title ?? '', diff --git a/web/src/features/filtering/hooks/useFilteringContainer.ts b/web/src/features/filtering/hooks/useFilteringContainer.ts index 599365bfe..bdb922550 100644 --- a/web/src/features/filtering/hooks/useFilteringContainer.ts +++ b/web/src/features/filtering/hooks/useFilteringContainer.ts @@ -1,6 +1,6 @@ import { useSetPrevSearch } from '@/common/prev-search-store'; import { useNavigate, useSearch } from '@tanstack/react-router'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { HIDDEN_FILTERS } from '../common'; import { FILTER_KEY } from '../filtering-enums'; @@ -19,6 +19,12 @@ export function useFilteringContainer() { .some(([_, value]) => !!value); }, [queryParams]); + const [isFilteringContainerVisible, setIsFilteringContainerVisible] = useState(filteringIsActive); + + useEffect(() => { + setIsFilteringContainerVisible(filteringIsActive); + }, [filteringIsActive]); + const navigateHandler = useCallback( (search: Record) => { navigate({ @@ -37,6 +43,8 @@ export function useFilteringContainer() { [navigate, setPrevSearch] ); + const toggleFilteringContainerVisibility = () => setIsFilteringContainerVisible((prev) => !prev); + const resetFilters = () => { navigate({ to: '.', @@ -46,5 +54,13 @@ export function useFilteringContainer() { setPrevSearch(filterObject(queryParams, HIDDEN_FILTERS)); }; - return { queryParams, filteringIsActive, navigate, navigateHandler, resetFilters }; + return { + queryParams, + filteringIsActive, + isFilteringContainerVisible, + toggleFilteringContainerVisibility, + navigate, + navigateHandler, + resetFilters, + }; } diff --git a/web/src/features/monitoring-observers/components/EditMonitoringObserver/EditMonitoringObserver.tsx b/web/src/features/monitoring-observers/components/EditMonitoringObserver/EditMonitoringObserver.tsx index 58e1b6c16..76168f232 100644 --- a/web/src/features/monitoring-observers/components/EditMonitoringObserver/EditMonitoringObserver.tsx +++ b/web/src/features/monitoring-observers/components/EditMonitoringObserver/EditMonitoringObserver.tsx @@ -47,6 +47,7 @@ export default function EditObserver() { const form = useForm>({ resolver: zodResolver(editObserverFormSchema), + mode: 'all', defaultValues: { status: monitoringObserver.status, tags: monitoringObserver.tags, diff --git a/web/src/features/monitoring-observers/components/MonitoringObserversList/CreateMonitoringObserverDialog.tsx b/web/src/features/monitoring-observers/components/MonitoringObserversList/CreateMonitoringObserverDialog.tsx index 66d03559a..d03df72e1 100644 --- a/web/src/features/monitoring-observers/components/MonitoringObserversList/CreateMonitoringObserverDialog.tsx +++ b/web/src/features/monitoring-observers/components/MonitoringObserversList/CreateMonitoringObserverDialog.tsx @@ -36,6 +36,7 @@ function CreateMonitoringObserverDialog({ open, onOpenChange }: CreateMonitoring type ObserverFormData = z.infer; const form = useForm({ + mode: 'all', resolver: zodResolver(newObserverSchema), }); diff --git a/web/src/features/monitoring-observers/components/PushMessageForm/PushMessageForm.tsx b/web/src/features/monitoring-observers/components/PushMessageForm/PushMessageForm.tsx index 863a8a9db..b557e795a 100644 --- a/web/src/features/monitoring-observers/components/PushMessageForm/PushMessageForm.tsx +++ b/web/src/features/monitoring-observers/components/PushMessageForm/PushMessageForm.tsx @@ -103,6 +103,7 @@ function PushMessageForm(): FunctionComponent { const form = useForm>({ resolver: zodResolver(createPushMessageSchema), + mode: 'all', defaultValues: { title: '', messageBody: '', diff --git a/web/src/features/ngos/components/CreateNGODialog.tsx b/web/src/features/ngos/components/CreateNGODialog.tsx new file mode 100644 index 000000000..539abf42d --- /dev/null +++ b/web/src/features/ngos/components/CreateNGODialog.tsx @@ -0,0 +1,114 @@ +import { Button } from '@/components/ui/button'; +import { Dialog, DialogClose, DialogContent, DialogFooter, DialogTitle } from '@/components/ui/dialog'; +import { Form, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { toast } from '@/components/ui/use-toast'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { useCreateNgo } from '../hooks/ngos-queries'; +import { newNgoSchema, NgoCreationFormData } from '../models/NGO'; +import { useCallback, useEffect } from 'react'; + +export interface CreateNGODialogProps { + open: boolean; + onOpenChange: (open: any) => void; +} + +function CreateNGODialog({ open, onOpenChange }: CreateNGODialogProps) { + const { createNgoMutation } = useCreateNgo(); + + const form = useForm({ + resolver: zodResolver(newNgoSchema), + defaultValues: { + name: '', + }, + }); + + useEffect(() => { + if (form.formState.isSubmitSuccessful) { + form.reset(undefined, { keepValues: true }); + } + }, [form.formState.isSubmitSuccessful]); + + const internalOnOpenChange = useCallback( + (open: boolean) => { + if (!open) { + form.reset({ + name: '', + }); + } + onOpenChange(open); + }, + [onOpenChange] + ); + + function onSubmit(values: NgoCreationFormData) { + createNgoMutation.mutate({ + values, + onMutationSuccess: () => { + form.reset({}); + internalOnOpenChange(false); + toast({ + title: 'Success', + description: 'New organization created', + }); + }, + onMutationError: (error) => { + error?.errors?.forEach((error) => { + form.setError(error.name as keyof NgoCreationFormData, { type: 'custom', message: error.reason }); + }); + + toast({ + title: 'Error adding NGO admin', + description: 'Please contact Platform admins', + variant: 'destructive', + }); + }, + }); + } + + return ( + + { + e.preventDefault(); + }} + onEscapeKeyDown={(e) => { + e.preventDefault(); + }}> + Add organization +
+
+ + ( + + Name + + + + )} + /> + + + + + + + + + +
+
+
+ ); +} + +export default CreateNGODialog; diff --git a/web/src/features/ngos/components/Dashboard/Dashboard.tsx b/web/src/features/ngos/components/Dashboard/Dashboard.tsx index 700e5577a..9de3ee33c 100644 --- a/web/src/features/ngos/components/Dashboard/Dashboard.tsx +++ b/web/src/features/ngos/components/Dashboard/Dashboard.tsx @@ -1,47 +1,177 @@ -import { ngoColDefs, type NGO } from '../../models/NGO'; -import { useCallback, type ReactElement } from 'react'; -import { type UseQueryResult, useQuery } from '@tanstack/react-query'; -import type { DataTableParameters, PageResponse } from '@/common/types'; -import { authApi } from '@/common/auth-api'; -import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable'; import Layout from '@/components/layout/Layout'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumnHeader'; +import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; +import { useDialog } from '@/components/ui/use-dialog'; +import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; +import { useDebouncedSearch } from '@/hooks/debounced-search'; +import { ngoRouteSearchSchema, Route } from '@/routes/ngos'; +import { Cog8ToothIcon, FunnelIcon } from '@heroicons/react/24/outline'; +import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; import { useNavigate } from '@tanstack/react-router'; +import type { ColumnDef } from '@tanstack/react-table'; +import { Plus } from 'lucide-react'; +import { useCallback, type ReactElement } from 'react'; +import { useNgoMutations, useNGOs } from '../../hooks/ngos-queries'; +import { NGO, NGOStatus } from '../../models/NGO'; +import CreateNGODialog from '../CreateNGODialog'; +import { NgoStatusBadge } from '../NgoStatusBadges'; -function useNGOs(p: DataTableParameters): UseQueryResult, Error> { - return useQuery({ - queryKey: ['ngos', p.pageNumber, p.pageSize, p.sortColumnName, p.sortOrder], - queryFn: async () => { - const response = await authApi.get>('/ngos', { - params: { - PageNumber: p.pageNumber, - PageSize: p.pageSize, - SortColumnName: p.sortColumnName, - SortOrder: p.sortOrder, - }, - }); +export default function NGOsDashboard(): ReactElement { + const navigate = useNavigate(); + const { isFilteringContainerVisible, toggleFilteringContainerVisibility } = useFilteringContainer(); + const { searchText, handleSearchInput, queryParams } = useDebouncedSearch(Route.id, ngoRouteSearchSchema); + const createNgoDialog = useDialog(); - if (response.status !== 200) { - throw new Error('Failed to fetch ngos'); - } + const { deactivateNgoMutation, activateNgoMutation, deleteNgoWithConfirmation } = useNgoMutations(); - return response.data; + const navigateToViewNgo = useCallback( + (ngoId: string) => { + void navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId, tab: 'details' } }); }, - }); -} - -export default function NGOsDashboard(): ReactElement { - const navigate = useNavigate(); + [navigate] + ); - const navigateToNgo = useCallback( + const navigateToEditNgo = useCallback( (ngoId: string) => { - navigate({ to: '/ngos/$ngoId', params: { ngoId } }); + void navigate({ to: '/ngos/edit/$ngoId', params: { ngoId } }); }, [navigate] ); + const ngoColDefs: ColumnDef[] = [ + { + accessorKey: 'name', + enableSorting: true, + header: ({ column }) => , + }, + + { + accessorKey: 'numberOfNgoAdmins', + enableSorting: true, + header: ({ column }) => , + }, + + { + accessorKey: 'numberOfElectionsMonitoring', + enableSorting: true, + header: ({ column }) => , + }, + + { + accessorKey: 'dateOfLastElection', + enableSorting: false, + header: ({ column }) => , + cell: ({ + row: { + original: { dateOfLastElection }, + }, + }) => dateOfLastElection ?? 'N/A', + }, + + { + accessorKey: 'status', + enableSorting: false, + header: ({ column }) => , + cell: ({ + row: { + original: { status }, + }, + }) => , + }, + + { + id: 'actions', + cell: ({ row }) => { + const isNGOActive = row.original.status === NGOStatus.Activated; + + return ( +
+ + + + + + { + e.stopPropagation(); + navigateToEditNgo(row.original.id); + }}> + Edit + + { + e.stopPropagation(); + isNGOActive + ? deactivateNgoMutation.mutate(row.original.id) + : activateNgoMutation.mutate(row.original.id); + }}> + {isNGOActive ? 'Deactivate' : 'Activate'} + + { + e.stopPropagation(); + + await deleteNgoWithConfirmation({ ngoId: row.original.id, name: row.original.name }); + }}> + Delete + + + +
+ ); + }, + }, + ]; + return ( - - + + + +
+ All organizations +
+ + +
+
+ + +
+ + + +
+
+ + + +
); } diff --git a/web/src/features/ngos/components/EditNgo.tsx b/web/src/features/ngos/components/EditNgo.tsx new file mode 100644 index 000000000..15e0b4c53 --- /dev/null +++ b/web/src/features/ngos/components/EditNgo.tsx @@ -0,0 +1,131 @@ +import { BackButtonIcon } from '@/components/layout/Breadcrumbs/BackButton'; +import Layout from '@/components/layout/Layout'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; +import { TrashIcon } from '@heroicons/react/24/outline'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Link, useBlocker, useNavigate } from '@tanstack/react-router'; +import { FC, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { useNgoMutations } from '../hooks/ngos-queries'; +import { EditNgoFormData, editNgoSchema, NGO } from '../models/NGO'; +import { useConfirm } from '@/components/ui/alert-dialog-provider'; +import { DevTool } from '@hookform/devtools'; + +interface EditNgoAdminProps { + existingData: NGO; + ngoId: string; +} + +export const EditNgo: FC = ({ existingData, ngoId }) => { + const navigate = useNavigate(); + const confirm = useConfirm(); + + const { editNgoMutation, deleteNgoWithConfirmation } = useNgoMutations(); + + const form = useForm({ + resolver: zodResolver(editNgoSchema), + mode: 'all', + reValidateMode: 'onChange', + defaultValues: { + name: existingData.name ?? '', + }, + }); + + useEffect(() => { + if (form.formState.isSubmitSuccessful) { + form.reset({}, { keepValues: true }); + } + }, [form.formState.isSubmitSuccessful, form.reset]); + + function onSubmit(values: EditNgoFormData) { + editNgoMutation.mutate({ ngoId, values }); + } + + const handleDelete = async () => { + await deleteNgoWithConfirmation({ + ngoId, + name: existingData.name, + onMutationSuccess: () => { + navigate({ to: '/ngos' }); + }, + }); + }; + + useBlocker({ + shouldBlockFn: async () => { + if (!form.formState.isDirty || form.formState.isSubmitting) { + return false; + } + + return !(await confirm({ + title: `Unsaved Changes Detected`, + body: 'You have unsaved changes. If you leave this page, your changes will be lost. Are you sure you want to continue?', + actionButton: 'Leave', + cancelButton: 'Stay', + })); + }, + }); + + return ( + + + + } + breadcrumbs={<>}> + + +
+ Edit NGO +
+ +
+ +
+ + ( + + + Name * + + + + + + + )} + /> +
+ +
+ + +
+
+ + {/* set up the dev tool */} + +
+
+
+ ); +}; diff --git a/web/src/features/ngos/components/NGODetails.tsx b/web/src/features/ngos/components/NGODetails.tsx new file mode 100644 index 000000000..50d07ea33 --- /dev/null +++ b/web/src/features/ngos/components/NGODetails.tsx @@ -0,0 +1,107 @@ +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { PencilIcon } from '@heroicons/react/24/outline'; + +import { BackButtonIcon } from '@/components/layout/Breadcrumbs/BackButton'; +import Layout from '@/components/layout/Layout'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Route } from '@/routes/ngos/view.$ngoId.$tab'; +import { Link, useNavigate } from '@tanstack/react-router'; +import { FC } from 'react'; +import { NGO } from '../models/NGO'; +import { NGOAdminsView } from './admins/NGOAdmins'; +import { NgoStatusBadge } from './NgoStatusBadges'; + +interface NGODetailsProps { + data: NGO; +} + +export const NGODetailsView: FC = ({ data }) => { + const navigate = useNavigate(); + const navigateToEdit = () => { + navigate({ + to: '/ngos/edit/$ngoId', + params: { ngoId: data.id }, + }); + }; + + return ( + + +
+ Organization details + +
+ +
+ +
+

Name

+

{data.name}

+
+ +
+

Status

+ +
+ +
+

Admins

+

{data.numberOfNgoAdmins}

+
+ +
+

Election events

+

{data.numberOfElectionsMonitoring}

+
+ +
+

Date of last event

+

{data?.dateOfLastElection ?? 'N/A'}

+
+
+
+ ); +}; + +export const NGODetails: FC = ({ data }) => { + const { tab } = Route.useParams(); + const navigate = useNavigate(); + + function handleTabChange(tab: string): void { + navigate({ + replace: true, + // @ts-ignore + params(prev: any) { + return { ...prev, tab }; + }, + }); + } + return ( + + + + } + breadcrumbs={<>}> + + + Organization details + Admin users + + + + + + + + + + ); +}; diff --git a/web/src/features/ngos/components/NgoStatusBadges.tsx b/web/src/features/ngos/components/NgoStatusBadges.tsx new file mode 100644 index 000000000..19b5ed776 --- /dev/null +++ b/web/src/features/ngos/components/NgoStatusBadges.tsx @@ -0,0 +1,37 @@ +import { Badge } from '@/components/ui/badge'; +import { cn, mapNgoAdminStatus, mapNgoStatus } from '@/lib/utils'; +import { FC } from 'react'; +import { NGOStatus } from '../models/NGO'; +import { NgoAdminStatus } from '../models/NgoAdmin'; + +interface NgoStatusBadgeProps { + status: NGOStatus; +} + +export const NgoStatusBadge: FC = ({ status }) => { + return ( + + {mapNgoStatus(status)} + + ); +}; + +interface NgoAdmintatusBadgeProps { + status: NgoAdminStatus; +} + +export const NgoAdminStatusBadge: FC = ({ status }) => { + return ( + + {mapNgoAdminStatus(status)} + + ); +}; diff --git a/web/src/features/ngos/components/admins/AddNgoAdminDialog.tsx b/web/src/features/ngos/components/admins/AddNgoAdminDialog.tsx new file mode 100644 index 000000000..fd97bef1e --- /dev/null +++ b/web/src/features/ngos/components/admins/AddNgoAdminDialog.tsx @@ -0,0 +1,192 @@ +import { Button } from '@/components/ui/button'; +import { Dialog, DialogClose, DialogContent, DialogFooter, DialogTitle } from '@/components/ui/dialog'; +import { Form, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { toast } from '@/components/ui/use-toast'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { useCreateNgoAdmin } from '../../hooks/ngo-admin-queries'; +import { NgoAdminFormData, ngoAdminSchema } from '../../models/NgoAdmin'; +import { useCallback, useEffect } from 'react'; +import { useBlocker } from '@tanstack/react-router'; +import { useConfirm } from '@/components/ui/alert-dialog-provider'; + +export interface AddNgoAdminDialogProps { + ngoId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function AddNgoAdminDialog({ open, onOpenChange, ngoId }: AddNgoAdminDialogProps) { + const { createNgoAdminMutation } = useCreateNgoAdmin(); + const confirm = useConfirm(); + + const form = useForm({ + mode: 'all', + resolver: zodResolver(ngoAdminSchema), + }); + + useEffect(() => { + if (form.formState.isSubmitSuccessful) { + form.reset(undefined, { keepValues: true }); + } + }, [form.formState.isSubmitSuccessful]); + + const internalOnOpenChange = useCallback( + (open: boolean) => { + if (!open) { + form.reset({ + email: '', + firstName: '', + lastName: '', + password: '', + phoneNumber: '', + }); + } + onOpenChange(open); + }, + [onOpenChange] + ); + + useBlocker({ + shouldBlockFn: async () => { + if (!form.formState.isDirty) { + return false; + } + + return !(await confirm({ + title: `Unsaved Changes Detected`, + body: 'You have unsaved changes. If you leave this page, your changes will be lost. Are you sure you want to continue?', + actionButton: 'Leave', + cancelButton: 'Stay', + })); + }, + }); + + function onSubmit(values: NgoAdminFormData) { + createNgoAdminMutation.mutate({ + ngoId, + values, + onMutationSuccess: () => { + form.reset({}); + internalOnOpenChange(false); + toast({ + title: 'Success', + description: 'New NGO admin added', + }); + }, + onMutationError: (error) => { + error?.errors?.forEach((error) => { + form.setError(error.name as keyof NgoAdminFormData, { type: 'custom', message: error.reason }); + }); + + toast({ + title: 'Error adding NGO admin', + description: 'Please contact Platform admins', + variant: 'destructive', + }); + }, + }); + } + + return ( + + { + e.preventDefault(); + }} + onEscapeKeyDown={(e) => { + e.preventDefault(); + }}> + Add NGO admin +
+
+ + ( + + + First name * + + + + + )} + /> + + ( + + + Last name * + + + + + )} + /> + + ( + + + Email * + + + + + )} + /> + + ( + + Phone number + + + + )} + /> + + ( + + + Password * + + + + + )} + /> + + + + + + + + + +
+
+
+ ); +} + +export default AddNgoAdminDialog; diff --git a/web/src/features/ngos/components/admins/EditNgoAdmin.tsx b/web/src/features/ngos/components/admins/EditNgoAdmin.tsx new file mode 100644 index 000000000..a4a56d0b5 --- /dev/null +++ b/web/src/features/ngos/components/admins/EditNgoAdmin.tsx @@ -0,0 +1,184 @@ +import { BackButtonIcon } from '@/components/layout/Breadcrumbs/BackButton'; +import Layout from '@/components/layout/Layout'; +import { useConfirm } from '@/components/ui/alert-dialog-provider'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; +import { Route } from '@/routes/ngos/admin/$ngoId.$adminId.edit'; +import { TrashIcon } from '@heroicons/react/24/outline'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Link, useBlocker, useNavigate } from '@tanstack/react-router'; +import { FC, useEffect, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { useNgoAdminMutations } from '../../hooks/ngo-admin-queries'; +import { EditNgoAdminFormData, editNgoAdminSchema, NgoAdmin } from '../../models/NgoAdmin'; +import { NgoAdminStatusBadge } from '../NgoStatusBadges'; + +interface EditNgoAdminProps { + ngoAdmin: NgoAdmin; +} + +export const EditNgoAdmin: FC = ({ ngoAdmin }) => { + const navigate = useNavigate(); + const { ngoId, adminId } = Route.useParams(); + const { editNgoAdminMutation, deleteNgoAdminWithConfirmation } = useNgoAdminMutations(ngoId); + const confirm = useConfirm(); + + const displayName = useMemo( + () => `${ngoAdmin.firstName} ${ngoAdmin.lastName}`, + [ngoAdmin.firstName, ngoAdmin.lastName] + ); + + const form = useForm({ + resolver: zodResolver(editNgoAdminSchema), + mode: 'all', + reValidateMode: 'onChange', + defaultValues: { + firstName: ngoAdmin.firstName, + lastName: ngoAdmin.lastName, + phoneNumber: ngoAdmin.phoneNumber, + }, + }); + + useEffect(() => { + if (form.formState.isSubmitSuccessful) { + form.reset({}, { keepValues: true }); + } + }, [form.formState.isSubmitSuccessful, form.reset]); + + useBlocker({ + shouldBlockFn: async () => { + if (!form.formState.isDirty) { + return false; + } + + return !(await confirm({ + title: `Unsaved Changes Detected`, + body: 'You have unsaved changes. If you leave this page, your changes will be lost. Are you sure you want to continue?', + actionButton: 'Leave', + cancelButton: 'Stay', + })); + }, + }); + + function onSubmit(values: EditNgoAdminFormData) { + editNgoAdminMutation.mutate({ adminId, values }); + } + + const handleDelete = async () => { + await deleteNgoAdminWithConfirmation({ + adminId, + name: displayName, + onMutationSuccess: () => { + navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId, tab: 'admins' } }); + }, + }); + }; + + return ( + + + + } + breadcrumbs={<>}> + + +
+ Edit NGO admin +
+ +
+ +
+ +
+

Email

+

{ngoAdmin.email}

+
+ ( + + + First name * + + + + + + + )} + /> + + ( + + + Last name * + + + + + + + )} + /> + + ( + + Phone number + + + + + + )} + /> + +
+

Status

+ +
+ +
+ +
+ + + + + +
+
+ + +
+
+
+ ); +}; diff --git a/web/src/features/ngos/components/admins/NGOAdmins.tsx b/web/src/features/ngos/components/admins/NGOAdmins.tsx new file mode 100644 index 000000000..0cc7f56db --- /dev/null +++ b/web/src/features/ngos/components/admins/NGOAdmins.tsx @@ -0,0 +1,164 @@ +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumnHeader'; +import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; +import { useDialog } from '@/components/ui/use-dialog'; +import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; +import { useDebouncedSearch } from '@/hooks/debounced-search'; +import { ngoAdminsSearchParamsSchema, Route } from '@/routes/ngos/view.$ngoId.$tab'; +import { Cog8ToothIcon, FunnelIcon } from '@heroicons/react/24/outline'; +import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; +import { useNavigate } from '@tanstack/react-router'; +import type { ColumnDef } from '@tanstack/react-table'; +import { Plus } from 'lucide-react'; +import { FC, useCallback } from 'react'; +import { useNgoAdminMutations, useNgoAdmins } from '../../hooks/ngo-admin-queries'; +import { NgoAdmin, NgoAdminStatus } from '../../models/NgoAdmin'; +import { NgoAdminStatusBadge } from '../NgoStatusBadges'; +import AddNgoAdminDialog from './AddNgoAdminDialog'; + +interface NGOAdminsViewProps { + ngoId: string; +} + +export const NGOAdminsView: FC = ({ ngoId }) => { + const navigate = useNavigate(); + const { deleteNgoAdminWithConfirmation, deactivateNgoAdminMutation, activateNgoAdminMutation } = + useNgoAdminMutations(ngoId); + const { isFilteringContainerVisible, toggleFilteringContainerVisibility } = useFilteringContainer(); + const addNgoAdminDialog = useDialog(); + const { queryParams, searchText, handleSearchInput } = useDebouncedSearch(Route.id, ngoAdminsSearchParamsSchema); + + const navigateToViewNgoAdmin = useCallback( + (adminId: string) => navigate({ to: '/ngos/admin/$ngoId/$adminId/view', params: { ngoId, adminId } }), + [ngoId] + ); + + const navigateToEditNgoAdmin = useCallback( + (adminId: string) => navigate({ to: '/ngos/admin/$ngoId/$adminId/edit', params: { ngoId, adminId } }), + [ngoId] + ); + + const ngoAdminsColDefs: ColumnDef[] = [ + { + accessorKey: 'email', + enableSorting: true, + header: ({ column }) => , + }, + { + accessorKey: 'firstName', + enableSorting: true, + header: ({ column }) => , + }, + { + accessorKey: 'lastName', + enableSorting: true, + header: ({ column }) => , + }, + { + accessorKey: 'phoneNumber', + enableSorting: true, + header: ({ column }) => , + cell: ({ row: { original } }) => original.phoneNumber || '-', + }, + + { + accessorKey: 'status', + enableSorting: false, + header: ({ column }) => , + cell: ({ + row: { + original: { status }, + }, + }) => { + return ; + }, + }, + { + id: 'actions', + cell: ({ row }) => { + const adminId = row.original.id; + const adminName = `${row.original.firstName} ${row.original.lastName}`; + const isAdminActive = row.original.status === NgoAdminStatus.Active; + + return ( +
+ + + + + + navigateToViewNgoAdmin(adminId)}>View + navigateToEditNgoAdmin(adminId)}>Edit + { + e.stopPropagation(); + isAdminActive + ? deactivateNgoAdminMutation.mutate(adminId) + : activateNgoAdminMutation.mutate(adminId); + }}> + {!isAdminActive ? 'Activate' : 'Deactivate'} + + { + e.stopPropagation(); + await deleteNgoAdminWithConfirmation({ adminId, name: adminName }); + }}> + Delete + + + +
+ ); + }, + }, + ]; + + return ( + + +
+ All admins +
+ + +
+
+ + +
+ + + +
+
+ + useNgoAdmins(ngoId, params)} + queryParams={queryParams} + // onRowClick={(id) => navigateToViewNgoAdmin(id)} + /> + +
+ ); +}; diff --git a/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx b/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx new file mode 100644 index 000000000..083da4bea --- /dev/null +++ b/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx @@ -0,0 +1,83 @@ +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { PencilIcon } from '@heroicons/react/24/outline'; +import { Link, useNavigate } from '@tanstack/react-router'; + +import { DateTimeFormat } from '@/common/formats'; +import { BackButtonIcon } from '@/components/layout/Breadcrumbs/BackButton'; +import Layout from '@/components/layout/Layout'; +import { format } from 'date-fns'; +import { FC } from 'react'; +import { NgoAdmin } from '../../models/NgoAdmin'; +import { NgoAdminStatusBadge } from '../NgoStatusBadges'; + +interface NgoAdminDetailsViewProps { + ngoId: string; + ngoAdmin: NgoAdmin; +} + +export const NgoAdminDetailsView: FC = ({ ngoId, ngoAdmin }) => { + const navigate = useNavigate(); + const displayName = `${ngoAdmin.firstName} ${ngoAdmin.lastName}`; + const navigateToEdit = (): void => { + void navigate({ + to: '/ngos/admin/$ngoId/$adminId/edit', + params: { adminId: ngoAdmin.id, ngoId }, + }); + }; + + return ( + + + + } + breadcrumbs={<>}> + + +
+ NGO admin details + +
+ +
+ +
+

First name

+

{ngoAdmin.firstName}

+
+
+

Last name

+

{ngoAdmin.lastName}

+
+
+

Email

+

{ngoAdmin.email}

+
+
+

Phone

+

{ngoAdmin.phoneNumber}

+
+ +
+

Last activity

+

+ {' '} + {ngoAdmin.latestActivityAt ? format(ngoAdmin.latestActivityAt, DateTimeFormat) : '-'} +

+
+
+

Status

+ +
+
+
+
+ ); +}; diff --git a/web/src/features/ngos/hooks/ngo-admin-queries.ts b/web/src/features/ngos/hooks/ngo-admin-queries.ts new file mode 100644 index 000000000..be7d3b9b7 --- /dev/null +++ b/web/src/features/ngos/hooks/ngo-admin-queries.ts @@ -0,0 +1,229 @@ +import { authApi } from '@/common/auth-api'; +import { DataTableParameters, PageResponse, ProblemDetails } from '@/common/types'; +import { useConfirm } from '@/components/ui/alert-dialog-provider'; +import { buttonVariants } from '@/components/ui/button'; +import { toast } from '@/components/ui/use-toast'; +import { + queryOptions, + useMutation, + useQuery, + useQueryClient, + UseQueryResult, + useSuspenseQuery, +} from '@tanstack/react-query'; +import { useNavigate, useRouter } from '@tanstack/react-router'; +import { EditNgoAdminFormData, NgoAdmin, NgoAdminFormData, NgoAdminGetRequestParams } from '../models/NgoAdmin'; +import { ngosKeys } from './ngos-queries'; +import axios, { AxiosError } from 'axios'; +const STALE_TIME = 1000 * 10 * 60; // 10 minutes + +export const ngoAdminDetailsOptions = ({ ngoId, adminId }: NgoAdminGetRequestParams) => + queryOptions({ + queryKey: ngosKeys.adminDetails(ngoId, adminId), + queryFn: async () => { + const response = await authApi.get(`/ngos/${ngoId}/admins/${adminId}`); + + if (response.status !== 200) { + throw new Error('Failed to fetch ngo details'); + } + + return response.data; + }, + staleTime: STALE_TIME, + }); + +export const useNgoAdminDetails = ({ ngoId, adminId }: NgoAdminGetRequestParams) => + useSuspenseQuery(ngoAdminDetailsOptions({ ngoId, adminId })); + +export function useNgoAdmins(ngoId: string, p: DataTableParameters): UseQueryResult, Error> { + return useQuery({ + queryKey: ngosKeys.adminsList(ngoId, p), + queryFn: async () => { + const response = await authApi.get>(`/ngos/${ngoId}/admins`, { + params: { + ...p.otherParams, + }, + }); + + if (response.status !== 200) { + throw new Error('Failed to fetch ngo admins'); + } + + return response.data; + }, + staleTime: STALE_TIME, + }); +} + +// NGO ADMIN CREATION MUTATION + +export const useCreateNgoAdmin = () => { + const queryClient = useQueryClient(); + const createNgoAdminMutation = useMutation({ + mutationFn: ({ + ngoId, + values, + }: { + ngoId: string; + values: NgoAdminFormData; + onMutationSuccess: () => void; + onMutationError: (error?: ProblemDetails) => void; + }) => { + return authApi.post(`/ngos/${ngoId}/admins`, values); + }, + + onSuccess: (_, { onMutationSuccess }) => { + queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); + onMutationSuccess(); + }, + onError: (error, { onMutationError }) => { + if (axios.isAxiosError(error) && error.response) { + const axiosError = error as AxiosError; + + if (axiosError.response?.status === 400) { + const problemDetails = axiosError.response.data; + return onMutationError(problemDetails); + } + } + + // Handle non-Axios or unexpected errors + console.error('Unexpected error:', error); + onMutationError(); + }, + }); + + return { createNgoAdminMutation }; +}; + +export const useNgoAdminMutations = (ngoId: string) => { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const router = useRouter(); + const confirm = useConfirm(); + + // MUTATIONS + + const editNgoAdminMutation = useMutation({ + mutationFn: ({ adminId, values }: { adminId: string; values: EditNgoAdminFormData }) => { + return authApi.put(`/ngos/${ngoId}/admins/${adminId}`, values); + }, + + onSuccess: (_, { adminId }) => { + queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); + router.invalidate(); + navigate({ to: '/ngos/admin/$ngoId/$adminId/view', params: { ngoId, adminId } }); + }, + onError: () => { + toast({ + title: 'Error editing NGO admin', + description: '', + variant: 'destructive', + }); + }, + }); + + const deleteNgoAdminMutation = useMutation({ + mutationFn: ({ adminId }: { adminId: string; onMutationSuccess?: () => void }) => { + return authApi.delete(`/ngos/${ngoId}/admins/${adminId}`, {}); + }, + + onSuccess: (_, { onMutationSuccess }) => { + queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); + router.invalidate(); + + toast({ + title: 'Success', + description: 'NGO admin was deleted successfully', + }); + + if (onMutationSuccess) onMutationSuccess(); + }, + + onError: () => { + toast({ + title: 'Error deleting NGO admin', + description: '', + variant: 'destructive', + }); + }, + }); + + const deactivateNgoAdminMutation = useMutation({ + mutationFn: (adminId: string) => { + return authApi.post(`/ngos/${ngoId}/admins/${adminId}:deactivate`, {}); + }, + + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); + router.invalidate(); + + toast({ + title: 'Success', + description: 'NGO admin was deactivated successfully', + }); + }, + + onError: () => { + toast({ + title: 'Error deactivating the NGO admin', + description: '', + variant: 'destructive', + }); + }, + }); + + const activateNgoAdminMutation = useMutation({ + mutationFn: (adminId: string) => { + return authApi.post(`/ngos/${ngoId}/admins/${adminId}:activate`, {}); + }, + + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); + router.invalidate(); + + toast({ + title: 'Success', + description: 'NGO admin was activated successfully', + }); + }, + + onError: () => { + toast({ + title: 'Error activating the NGO admin', + description: '', + variant: 'destructive', + }); + }, + }); + + // WRAPPED MUTATIONS + + const deleteNgoAdminWithConfirmation = async ({ + adminId, + name, + onMutationSuccess, + }: { + adminId: string; + name: string; + onMutationSuccess?: () => void; + }) => { + if ( + await confirm({ + title: `Delete ${name}?`, + body: 'This action is permanent and cannot be undone. Once deleted, this NGO admin cannot be retrieved.', + actionButton: 'Delete', + actionButtonClass: buttonVariants({ variant: 'destructive' }), + cancelButton: 'Cancel', + }) + ) { + deleteNgoAdminMutation.mutate({ adminId, onMutationSuccess }); + } + }; + + return { + editNgoAdminMutation, + deactivateNgoAdminMutation, + activateNgoAdminMutation, + deleteNgoAdminWithConfirmation, + }; +}; diff --git a/web/src/features/ngos/hooks/ngos-queries.ts b/web/src/features/ngos/hooks/ngos-queries.ts new file mode 100644 index 000000000..5e62ca0e0 --- /dev/null +++ b/web/src/features/ngos/hooks/ngos-queries.ts @@ -0,0 +1,225 @@ +import { authApi } from '@/common/auth-api'; +import { DataTableParameters, PageResponse, ProblemDetails } from '@/common/types'; +import { useConfirm } from '@/components/ui/alert-dialog-provider'; +import { buttonVariants } from '@/components/ui/button'; +import { toast } from '@/components/ui/use-toast'; +import { queryClient } from '@/main'; +import { queryOptions, useMutation, useQuery, UseQueryResult, useSuspenseQuery } from '@tanstack/react-query'; +import { useNavigate, useRouter } from '@tanstack/react-router'; +import { EditNgoFormData, NGO, NgoCreationFormData } from '../models/NGO'; +import axios, { AxiosError } from 'axios'; + +const STALE_TIME = 1000 * 10 * 60; // 10 minutes + +export const ngosKeys = { + all: () => ['ngos'] as const, + lists: () => [...ngosKeys.all(), 'list'] as const, + list: (params: DataTableParameters) => [...ngosKeys.lists(), { ...params }] as const, + details: () => [...ngosKeys.all(), 'detail'] as const, + detail: (id: string) => [...ngosKeys.details(), id] as const, + adminDetails: (ngoId: string, ngoAdminId: string) => [...ngosKeys.detail(ngoId), 'admins', ngoAdminId] as const, + adminsList: (ngoId: string, params: DataTableParameters) => + [...ngosKeys.all(), 'admins', ngoId, { ...params }] as const, +}; + +export function useNGOs(p: DataTableParameters): UseQueryResult, Error> { + return useQuery({ + queryKey: ngosKeys.list(p), + queryFn: async () => { + const response = await authApi.get>('/ngos', { + params: { ...p.otherParams }, + }); + + if (response.status !== 200) { + throw new Error('Failed to fetch ngos'); + } + + return response.data; + }, + staleTime: STALE_TIME, + }); +} + +export const ngoDetailsOptions = (ngoId: string) => + queryOptions({ + queryKey: ngosKeys.detail(ngoId), + queryFn: async () => { + const response = await authApi.get(`/ngos/${ngoId}`); + + if (response.status !== 200) { + throw new Error('Failed to fetch ngo details'); + } + + return response.data; + }, + staleTime: STALE_TIME, + }); + +export const useNGODetails = (ngoId: string) => useSuspenseQuery(ngoDetailsOptions(ngoId)); + +export const useCreateNgo = () => { + const createNgoMutation = useMutation({ + mutationFn: ({ + values, + }: { + values: NgoCreationFormData; + onMutationSuccess: () => void; + onMutationError: (error?: ProblemDetails) => void; + }) => { + return authApi.post('/ngos', { name: values.name }); + }, + + onSuccess: (_, { onMutationSuccess }) => { + queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); + if (onMutationSuccess) onMutationSuccess(); + }, + onError: (error, { onMutationError }) => { + if (axios.isAxiosError(error) && error.response) { + const axiosError = error as AxiosError; + + if (axiosError.response?.status === 400) { + const problemDetails = axiosError.response.data; + return onMutationError(problemDetails); + } + } + + // Handle non-Axios or unexpected errors + console.error('Unexpected error:', error); + onMutationError(); + toast({ + title: 'Error creating a new NGO', + description: '', + variant: 'destructive', + }); + }, + }); + + return { + createNgoMutation, + }; +}; + +export const useNgoMutations = () => { + const router = useRouter(); + const confirm = useConfirm(); + const navigate = useNavigate(); + + const editNgoMutation = useMutation({ + mutationFn: ({ ngoId, values }: { ngoId: string; values: EditNgoFormData }) => { + return authApi.put(`/ngos/${ngoId}`, values); + }, + + onSuccess: (_, { ngoId }) => { + queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); + router.invalidate(); + navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId: ngoId!, tab: 'details' } }); + }, + onError: () => { + toast({ + title: 'Error editing NGO', + description: '', + variant: 'destructive', + }); + }, + }); + + const deactivateNgoMutation = useMutation({ + mutationFn: (ngoId: string) => { + return authApi.post(`/ngos/${ngoId}:deactivate`, {}); + }, + + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); + router.invalidate(); + + toast({ + title: 'Success', + description: 'NGO was deactivated successfully', + }); + }, + + onError: () => { + toast({ + title: 'Error deactivating NGO', + description: '', + variant: 'destructive', + }); + }, + }); + + const activateNgoMutation = useMutation({ + mutationFn: (ngoId: string) => { + return authApi.post(`/ngos/${ngoId}:activate`, {}); + }, + + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); + router.invalidate(); + + toast({ + title: 'Success', + description: 'NGO was activated successfully', + }); + }, + + onError: () => { + toast({ + title: 'Error activating NGO', + description: '', + variant: 'destructive', + }); + }, + }); + + const deleteNgoMutation = useMutation({ + mutationFn: ({ ngoId }: { ngoId: string; onMutationSuccess?: () => void }) => { + return authApi.delete(`/ngos/${ngoId}`); + }, + + onSuccess: (_, { onMutationSuccess }) => { + queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); + router.invalidate(); + + toast({ + title: 'Success', + description: 'NGO was deleted successfully', + }); + + if (onMutationSuccess) onMutationSuccess(); + }, + + onError: () => { + toast({ + title: 'Error deleting NGO', + description: '', + variant: 'destructive', + }); + }, + }); + + // WRAPPED MUTATIONS + + const deleteNgoWithConfirmation = async ({ + ngoId, + name, + onMutationSuccess, + }: { + ngoId: string; + name: string; + onMutationSuccess?: () => void; + }) => { + if ( + await confirm({ + title: `Delete ${name}?`, + body: 'This action is permanent and cannot be undone. Once deleted, this organization cannot be retrieved.', + actionButton: 'Delete', + actionButtonClass: buttonVariants({ variant: 'destructive' }), + cancelButton: 'Cancel', + }) + ) { + deleteNgoMutation.mutate({ ngoId, onMutationSuccess }); + } + }; + + return { editNgoMutation, deactivateNgoMutation, activateNgoMutation, deleteNgoWithConfirmation }; +}; diff --git a/web/src/features/ngos/models/NGO.tsx b/web/src/features/ngos/models/NGO.tsx index 948542389..720599337 100644 --- a/web/src/features/ngos/models/NGO.tsx +++ b/web/src/features/ngos/models/NGO.tsx @@ -1,60 +1,34 @@ /* eslint-disable unicorn/prefer-top-level-await */ -import type { ColumnDef } from '@tanstack/react-table'; -import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumnHeader'; -import { useNavigate } from '@tanstack/react-router'; + +import { z } from 'zod'; export interface NGO { id: string; name: string; - status: string; + status: NGOStatus; + numberOfNgoAdmins: number; + numberOfElectionsMonitoring: number; + dateOfLastElection: string; +} + +export enum NGOStatus { + Activated = 'Activated', + Deactivated = 'Deactivated', } -export const ngoColDefs: ColumnDef[] = [ - { - header: 'ID', - accessorKey: 'id', - }, - { - accessorKey: 'name', - enableSorting: true, - header: ({ column }) => , - }, - { - accessorKey: 'status', - enableSorting: false, - header: ({ column }) => , - }, - { - id: 'actions', - cell: ({ row }) => { +export const newNgoSchema = z.object({ + name: z + .string() + .min(2, { message: 'Name must be at least 2 characters.' }) + .max(256, { message: 'Name must not exceed 256 characters.' }), +}); - const navigate = useNavigate(); +export type NgoCreationFormData = z.infer; +export const editNgoSchema = z.object({ + name: z + .string() + .min(2, { message: 'Name must be at least 2 characters.' }) + .max(256, { message: 'Name must not exceed 256 characters.' }), +}); - return ( -
- - - - - - navigate({ to: '/ngos/$ngoId', params: { ngoId: row.original.id } })}>Edit - Deactivate - Delete - - -
- ); - }, - }, -]; +export type EditNgoFormData = z.infer; diff --git a/web/src/features/ngos/models/NgoAdmin.tsx b/web/src/features/ngos/models/NgoAdmin.tsx new file mode 100644 index 000000000..183c57baf --- /dev/null +++ b/web/src/features/ngos/models/NgoAdmin.tsx @@ -0,0 +1,53 @@ +import { z } from 'zod'; + +export enum NgoAdminStatus { + Active = 'Active', + Deactivated = 'Deactivated', +} + +export type NgoAdminGetRequestParams = { ngoId: string; adminId: string }; + +export interface NgoAdmin { + id: string; + email: string; + firstName: string; + lastName: string; + status: NgoAdminStatus; + phoneNumber: string; + latestActivityAt: any; +} + +export const ngoAdminSchema = z.object({ + firstName: z + .string() + .min(1, { message: 'First name is required' }) + .max(256, { message: 'First name cannot exceed 256 characters' }), + lastName: z + .string() + .min(1, { message: 'Last name is required' }) + .max(256, { message: 'Last name cannot exceed 256 characters' }), + email: z + .string() + .email({ message: 'Invalid email format' }) + .max(256, { message: 'Email cannot exceed 256 characters' }), + phoneNumber: z.string().max(256, { message: 'Phone number cannot exceed 256 characters' }).optional(), + password: z + .string() + .min(1, { message: 'Password is required' }) + .max(256, { message: 'Password cannot exceed 256 characters' }), +}); + +export const editNgoAdminSchema = z.object({ + firstName: z + .string() + .min(2, { message: 'First name must be at least 2 characters' }) + .max(256, { message: 'First name cannot exceed 256 characters' }), + lastName: z + .string() + .min(2, { message: 'Last name must be at least 2 characters' }) + .max(256, { message: 'Last name cannot exceed 256 characters' }), + phoneNumber: z.coerce.string().max(256, { message: 'Phone number cannot exceed 256 characters' }).optional(), +}); + +export type NgoAdminFormData = z.infer; +export type EditNgoAdminFormData = z.infer; diff --git a/web/src/features/observers/components/EditObserver/EditObserver.tsx b/web/src/features/observers/components/EditObserver/EditObserver.tsx index fe165e64b..f04b302a8 100644 --- a/web/src/features/observers/components/EditObserver/EditObserver.tsx +++ b/web/src/features/observers/components/EditObserver/EditObserver.tsx @@ -1,5 +1,6 @@ import { authApi } from '@/common/auth-api'; import Layout from '@/components/layout/Layout'; +import { useConfirm } from '@/components/ui/alert-dialog-provider'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; @@ -10,7 +11,7 @@ import { Route } from '@/routes/observers_.$observerId.edit'; import { TrashIcon } from '@heroicons/react/24/outline'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; -import { useNavigate } from '@tanstack/react-router'; +import { useBlocker, useNavigate } from '@tanstack/react-router'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -19,6 +20,7 @@ export default function EditObserver() { const { observerId } = Route.useParams(); const observerQuery = useSuspenseQuery(observerDetailsQueryOptions(observerId)); const observer = observerQuery.data; + const confirm = useConfirm(); const editObserverFormSchema = z.object({ lastName: z.string().min(1, { @@ -33,6 +35,7 @@ export default function EditObserver() { const form = useForm>({ resolver: zodResolver(editObserverFormSchema), + mode: 'all', defaultValues: { firstName: observer.firstName, lastName: observer.lastName, @@ -41,6 +44,21 @@ export default function EditObserver() { }, }); + useBlocker({ + shouldBlockFn: async () => { + if (!form.formState.isDirty) { + return false; + } + + return !(await confirm({ + title: `Unsaved Changes Detected`, + body: 'You have unsaved changes. If you leave this page, your changes will be lost. Are you sure you want to continue?', + actionButton: 'Leave', + cancelButton: 'Stay', + })); + }, + }); + function onSubmit(values: z.infer) { editMutation.mutate({ observerId: observer.id, diff --git a/web/src/hooks/debounced-search.ts b/web/src/hooks/debounced-search.ts new file mode 100644 index 000000000..738857d17 --- /dev/null +++ b/web/src/hooks/debounced-search.ts @@ -0,0 +1,36 @@ +import { FILTER_KEY } from '@/features/filtering/filtering-enums'; +import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; +import { getRouteApi } from '@tanstack/react-router'; +import { useDebounce } from '@uidotdev/usehooks'; +import { useEffect, useMemo, useState } from 'react'; +import { z, ZodSchema } from 'zod'; + +export const useDebouncedSearch = (routeId: string, schema: ZodSchema) => { + type SchemaType = z.infer; + type SchemaWithSearchText = SchemaType & { searchText: string }; + + const { navigateHandler } = useFilteringContainer(); + const routerApi = getRouteApi(routeId as any); + //@ts-ignore + const search = routerApi.useSearch(); + const [searchText, setSearchText] = useState(search.searchText); + const handleSearchInput = (ev: React.FormEvent) => { + setSearchText(ev.currentTarget.value); + }; + + const debouncedSearchParams = useDebounce(search, 300); + const debouncedSearchText = useDebounce(searchText, 300); + + const queryParams = useMemo(() => { + const params = Object.entries(debouncedSearchParams).filter(([_, value]) => value); + return Object.fromEntries(params); + }, [debouncedSearchParams]); + + useEffect(() => { + navigateHandler({ + [FILTER_KEY.SearchText]: debouncedSearchText, + }); + }, [debouncedSearchText]); + + return { searchParams: debouncedSearchParams, searchText, queryParams, handleSearchInput }; +}; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 84f122275..4de0fae86 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -570,6 +570,8 @@ export function omit(obj: T, key: K): Omit { } import { authApi } from '@/common/auth-api'; +import { NGOStatus } from '@/features/ngos/models/NGO'; +import { NgoAdminStatus } from '@/features/ngos/models/NgoAdmin'; export enum TemplateType { MonitoringObservers = 'monitoring-observers', @@ -612,3 +614,27 @@ export const downloadImportExample = async (templateType: TemplateType) => { window.URL.revokeObjectURL(url); }; + +export function mapNgoStatus(ngoStatus: NGOStatus): string { + switch (ngoStatus) { + case NGOStatus.Activated: + return i18n.t('ngo.status.activated'); + case NGOStatus.Deactivated: + return i18n.t('ngo.status.deactivated'); + + default: + return 'Unknown'; + } +} + +export function mapNgoAdminStatus(ngoAdminStatus: NgoAdminStatus): string { + switch (ngoAdminStatus) { + case NgoAdminStatus.Active: + return i18n.t('ngo-admin.status.active'); + case NgoAdminStatus.Deactivated: + return i18n.t('ngo-admin.status.deactivated'); + + default: + return 'Unknown'; + } +} diff --git a/web/src/locales/en.json b/web/src/locales/en.json index b887599c1..87fa431f6 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -32,7 +32,7 @@ } }, "app.input.pleaseSpecify": "Please specify", - "electionRound":{ + "electionRound": { "action.create": "Create new election event", "field.country": "Country", "field.englishTitle": "English title", @@ -40,9 +40,9 @@ "field.title": "Title", "placeholder.englishTitle": "Title in English", "placeholder.title": "Title", - "status":{ - "notStarted":"Not started", - "started":"Started", + "status": { + "notStarted": "Not started", + "started": "Started", "archived": "Archived" } }, @@ -448,5 +448,17 @@ "title": "Responses", "subtitle": "View all form answers and other reports submitted by your observers.", "subtitleCoalitionLeader": "View all form answers and other reports submitted by coalition observers." + }, + "ngo": { + "status": { + "activated": "Activated", + "deactivated": "Deactivated" + } + }, + "ngo-admin": { + "status": { + "active": "Active", + "deactivated": "Deactivated" + } } } diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 9abd96264..4df72d17b 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -26,7 +26,6 @@ import { Route as AcceptInviteIndexImport } from './routes/accept-invite/index' import { Route as ResetPasswordSuccessImport } from './routes/reset-password/success' import { Route as ObserversObserverIdImport } from './routes/observers/$observerId' import { Route as ObserverGuidesNewImport } from './routes/observer-guides/new' -import { Route as NgosNgoIdImport } from './routes/ngos/$ngoId' import { Route as MonitoringObserversImportImport } from './routes/monitoring-observers/import' import { Route as MonitoringObserversCreateNewMessageImport } from './routes/monitoring-observers/create-new-message' import { Route as MonitoringObserversTabImport } from './routes/monitoring-observers/$tab' @@ -63,6 +62,7 @@ import { Route as CitizenGuidesEditGuideIdImport } from './routes/citizen-guides import { Route as ResponsesIncidentReportsFormIdAggregatedImport } from './routes/responses/incident-reports/$formId.aggregated' import { Route as ResponsesFormSubmissionsFormIdAggregatedImport } from './routes/responses/form-submissions/$formId.aggregated' import { Route as ResponsesCitizenReportsFormIdAggregatedImport } from './routes/responses/citizen-reports/$formId.aggregated' +import { Route as NgosViewNgoIdTabImport } from './routes/ngos/view.$ngoId.$tab' import { Route as MonitoringObserversViewMonitoringObserverIdTabImport } from './routes/monitoring-observers/view/$monitoringObserverId.$tab' import { Route as MonitoringObserversPushMessagesIdViewImport } from './routes/monitoring-observers/push-messages.$id_.view' import { Route as FormsFormIdEditTranslationLanguageCodeImport } from './routes/forms/$formId_.edit-translation.$languageCode' @@ -70,6 +70,9 @@ import { Route as FormTemplatesFormTemplateIdEditTranslationLanguageCodeImport } import { Route as ElectionRoundsElectionRoundIdPollingStationsImportImport } from './routes/election-rounds/$electionRoundId/polling-stations/import' import { Route as ElectionRoundsElectionRoundIdLocationsImportImport } from './routes/election-rounds/$electionRoundId/locations/import' import { Route as CitizenReportAttachmentsElectionRoundIdCitizenReportIdAttachmentIdImport } from './routes/citizen-report-attachments/$electionRoundId.$citizenReportId.$attachmentId' +import { Route as NgosEditNgoIdImport } from './routes/ngos/edit.$ngoId.' +import { Route as NgosAdminNgoIdAdminIdViewImport } from './routes/ngos/admin/$ngoId.$adminId.view' +import { Route as NgosAdminNgoIdAdminIdEditImport } from './routes/ngos/admin/$ngoId.$adminId.edit' // Create/Update Routes @@ -163,12 +166,6 @@ const ObserverGuidesNewRoute = ObserverGuidesNewImport.update({ getParentRoute: () => rootRoute, } as any) -const NgosNgoIdRoute = NgosNgoIdImport.update({ - id: '/ngos/$ngoId', - path: '/ngos/$ngoId', - getParentRoute: () => rootRoute, -} as any) - const MonitoringObserversImportRoute = MonitoringObserversImportImport.update({ id: '/monitoring-observers/import', path: '/monitoring-observers/import', @@ -402,6 +399,12 @@ const ResponsesCitizenReportsFormIdAggregatedRoute = getParentRoute: () => rootRoute, } as any) +const NgosViewNgoIdTabRoute = NgosViewNgoIdTabImport.update({ + id: '/ngos/view/$ngoId/$tab', + path: '/ngos/view/$ngoId/$tab', + getParentRoute: () => rootRoute, +} as any) + const MonitoringObserversViewMonitoringObserverIdTabRoute = MonitoringObserversViewMonitoringObserverIdTabImport.update({ id: '/monitoring-observers/view/$monitoringObserverId/$tab', @@ -453,6 +456,24 @@ const CitizenReportAttachmentsElectionRoundIdCitizenReportIdAttachmentIdRoute = } as any, ) +const NgosEditNgoIdRoute = NgosEditNgoIdImport.update({ + id: '/ngos/edit/$ngoId/', + path: '/ngos/edit/$ngoId/', + getParentRoute: () => rootRoute, +} as any) + +const NgosAdminNgoIdAdminIdViewRoute = NgosAdminNgoIdAdminIdViewImport.update({ + id: '/ngos/admin/$ngoId/$adminId/view', + path: '/ngos/admin/$ngoId/$adminId/view', + getParentRoute: () => rootRoute, +} as any) + +const NgosAdminNgoIdAdminIdEditRoute = NgosAdminNgoIdAdminIdEditImport.update({ + id: '/ngos/admin/$ngoId/$adminId/edit', + path: '/ngos/admin/$ngoId/$adminId/edit', + getParentRoute: () => rootRoute, +} as any) + // Populate the FileRoutesByPath interface declare module '@tanstack/react-router' { @@ -541,13 +562,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MonitoringObserversImportImport parentRoute: typeof rootRoute } - '/ngos/$ngoId': { - id: '/ngos/$ngoId' - path: '/ngos/$ngoId' - fullPath: '/ngos/$ngoId' - preLoaderRoute: typeof NgosNgoIdImport - parentRoute: typeof rootRoute - } '/observer-guides/new': { id: '/observer-guides/new' path: '/observer-guides/new' @@ -800,6 +814,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ElectionRoundsElectionRoundIdIndexImport parentRoute: typeof rootRoute } + '/ngos/edit/$ngoId/': { + id: '/ngos/edit/$ngoId/' + path: '/ngos/edit/$ngoId' + fullPath: '/ngos/edit/$ngoId' + preLoaderRoute: typeof NgosEditNgoIdImport + parentRoute: typeof rootRoute + } '/citizen-report-attachments/$electionRoundId/$citizenReportId/$attachmentId': { id: '/citizen-report-attachments/$electionRoundId/$citizenReportId/$attachmentId' path: '/citizen-report-attachments/$electionRoundId/$citizenReportId/$attachmentId' @@ -849,6 +870,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MonitoringObserversViewMonitoringObserverIdTabImport parentRoute: typeof rootRoute } + '/ngos/view/$ngoId/$tab': { + id: '/ngos/view/$ngoId/$tab' + path: '/ngos/view/$ngoId/$tab' + fullPath: '/ngos/view/$ngoId/$tab' + preLoaderRoute: typeof NgosViewNgoIdTabImport + parentRoute: typeof rootRoute + } '/responses/citizen-reports/$formId/aggregated': { id: '/responses/citizen-reports/$formId/aggregated' path: '/responses/citizen-reports/$formId/aggregated' @@ -870,6 +898,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ResponsesIncidentReportsFormIdAggregatedImport parentRoute: typeof rootRoute } + '/ngos/admin/$ngoId/$adminId/edit': { + id: '/ngos/admin/$ngoId/$adminId/edit' + path: '/ngos/admin/$ngoId/$adminId/edit' + fullPath: '/ngos/admin/$ngoId/$adminId/edit' + preLoaderRoute: typeof NgosAdminNgoIdAdminIdEditImport + parentRoute: typeof rootRoute + } + '/ngos/admin/$ngoId/$adminId/view': { + id: '/ngos/admin/$ngoId/$adminId/view' + path: '/ngos/admin/$ngoId/$adminId/view' + fullPath: '/ngos/admin/$ngoId/$adminId/view' + preLoaderRoute: typeof NgosAdminNgoIdAdminIdViewImport + parentRoute: typeof rootRoute + } } } @@ -888,7 +930,6 @@ export interface FileRoutesByFullPath { '/monitoring-observers/$tab': typeof MonitoringObserversTabRoute '/monitoring-observers/create-new-message': typeof MonitoringObserversCreateNewMessageRoute '/monitoring-observers/import': typeof MonitoringObserversImportRoute - '/ngos/$ngoId': typeof NgosNgoIdRoute '/observer-guides/new': typeof ObserverGuidesNewRoute '/observers/$observerId': typeof ObserversObserverIdRoute '/reset-password/success': typeof ResetPasswordSuccessRoute @@ -925,6 +966,7 @@ export interface FileRoutesByFullPath { '/responses/incident-reports/$incidentReportId': typeof ResponsesIncidentReportsIncidentReportIdRoute '/responses/quick-reports/$quickReportId': typeof ResponsesQuickReportsQuickReportIdRoute '/election-rounds/$electionRoundId': typeof ElectionRoundsElectionRoundIdIndexRoute + '/ngos/edit/$ngoId': typeof NgosEditNgoIdRoute '/citizen-report-attachments/$electionRoundId/$citizenReportId/$attachmentId': typeof CitizenReportAttachmentsElectionRoundIdCitizenReportIdAttachmentIdRoute '/election-rounds/$electionRoundId/locations/import': typeof ElectionRoundsElectionRoundIdLocationsImportRoute '/election-rounds/$electionRoundId/polling-stations/import': typeof ElectionRoundsElectionRoundIdPollingStationsImportRoute @@ -932,9 +974,12 @@ export interface FileRoutesByFullPath { '/forms/$formId/edit-translation/$languageCode': typeof FormsFormIdEditTranslationLanguageCodeRoute '/monitoring-observers/push-messages/$id/view': typeof MonitoringObserversPushMessagesIdViewRoute '/monitoring-observers/view/$monitoringObserverId/$tab': typeof MonitoringObserversViewMonitoringObserverIdTabRoute + '/ngos/view/$ngoId/$tab': typeof NgosViewNgoIdTabRoute '/responses/citizen-reports/$formId/aggregated': typeof ResponsesCitizenReportsFormIdAggregatedRoute '/responses/form-submissions/$formId/aggregated': typeof ResponsesFormSubmissionsFormIdAggregatedRoute '/responses/incident-reports/$formId/aggregated': typeof ResponsesIncidentReportsFormIdAggregatedRoute + '/ngos/admin/$ngoId/$adminId/edit': typeof NgosAdminNgoIdAdminIdEditRoute + '/ngos/admin/$ngoId/$adminId/view': typeof NgosAdminNgoIdAdminIdViewRoute } export interface FileRoutesByTo { @@ -950,7 +995,6 @@ export interface FileRoutesByTo { '/monitoring-observers/$tab': typeof MonitoringObserversTabRoute '/monitoring-observers/create-new-message': typeof MonitoringObserversCreateNewMessageRoute '/monitoring-observers/import': typeof MonitoringObserversImportRoute - '/ngos/$ngoId': typeof NgosNgoIdRoute '/observer-guides/new': typeof ObserverGuidesNewRoute '/observers/$observerId': typeof ObserversObserverIdRoute '/reset-password/success': typeof ResetPasswordSuccessRoute @@ -987,6 +1031,7 @@ export interface FileRoutesByTo { '/responses/incident-reports/$incidentReportId': typeof ResponsesIncidentReportsIncidentReportIdRoute '/responses/quick-reports/$quickReportId': typeof ResponsesQuickReportsQuickReportIdRoute '/election-rounds/$electionRoundId': typeof ElectionRoundsElectionRoundIdIndexRoute + '/ngos/edit/$ngoId': typeof NgosEditNgoIdRoute '/citizen-report-attachments/$electionRoundId/$citizenReportId/$attachmentId': typeof CitizenReportAttachmentsElectionRoundIdCitizenReportIdAttachmentIdRoute '/election-rounds/$electionRoundId/locations/import': typeof ElectionRoundsElectionRoundIdLocationsImportRoute '/election-rounds/$electionRoundId/polling-stations/import': typeof ElectionRoundsElectionRoundIdPollingStationsImportRoute @@ -994,9 +1039,12 @@ export interface FileRoutesByTo { '/forms/$formId/edit-translation/$languageCode': typeof FormsFormIdEditTranslationLanguageCodeRoute '/monitoring-observers/push-messages/$id/view': typeof MonitoringObserversPushMessagesIdViewRoute '/monitoring-observers/view/$monitoringObserverId/$tab': typeof MonitoringObserversViewMonitoringObserverIdTabRoute + '/ngos/view/$ngoId/$tab': typeof NgosViewNgoIdTabRoute '/responses/citizen-reports/$formId/aggregated': typeof ResponsesCitizenReportsFormIdAggregatedRoute '/responses/form-submissions/$formId/aggregated': typeof ResponsesFormSubmissionsFormIdAggregatedRoute '/responses/incident-reports/$formId/aggregated': typeof ResponsesIncidentReportsFormIdAggregatedRoute + '/ngos/admin/$ngoId/$adminId/edit': typeof NgosAdminNgoIdAdminIdEditRoute + '/ngos/admin/$ngoId/$adminId/view': typeof NgosAdminNgoIdAdminIdViewRoute } export interface FileRoutesById { @@ -1013,7 +1061,6 @@ export interface FileRoutesById { '/monitoring-observers/$tab': typeof MonitoringObserversTabRoute '/monitoring-observers/create-new-message': typeof MonitoringObserversCreateNewMessageRoute '/monitoring-observers/import': typeof MonitoringObserversImportRoute - '/ngos/$ngoId': typeof NgosNgoIdRoute '/observer-guides/new': typeof ObserverGuidesNewRoute '/observers/$observerId': typeof ObserversObserverIdRoute '/reset-password/success': typeof ResetPasswordSuccessRoute @@ -1050,6 +1097,7 @@ export interface FileRoutesById { '/responses/incident-reports/$incidentReportId': typeof ResponsesIncidentReportsIncidentReportIdRoute '/responses/quick-reports/$quickReportId': typeof ResponsesQuickReportsQuickReportIdRoute '/election-rounds/$electionRoundId/': typeof ElectionRoundsElectionRoundIdIndexRoute + '/ngos/edit/$ngoId/': typeof NgosEditNgoIdRoute '/citizen-report-attachments/$electionRoundId/$citizenReportId/$attachmentId': typeof CitizenReportAttachmentsElectionRoundIdCitizenReportIdAttachmentIdRoute '/election-rounds/$electionRoundId/locations/import': typeof ElectionRoundsElectionRoundIdLocationsImportRoute '/election-rounds/$electionRoundId/polling-stations/import': typeof ElectionRoundsElectionRoundIdPollingStationsImportRoute @@ -1057,9 +1105,12 @@ export interface FileRoutesById { '/forms/$formId_/edit-translation/$languageCode': typeof FormsFormIdEditTranslationLanguageCodeRoute '/monitoring-observers/push-messages/$id_/view': typeof MonitoringObserversPushMessagesIdViewRoute '/monitoring-observers/view/$monitoringObserverId/$tab': typeof MonitoringObserversViewMonitoringObserverIdTabRoute + '/ngos/view/$ngoId/$tab': typeof NgosViewNgoIdTabRoute '/responses/citizen-reports/$formId/aggregated': typeof ResponsesCitizenReportsFormIdAggregatedRoute '/responses/form-submissions/$formId/aggregated': typeof ResponsesFormSubmissionsFormIdAggregatedRoute '/responses/incident-reports/$formId/aggregated': typeof ResponsesIncidentReportsFormIdAggregatedRoute + '/ngos/admin/$ngoId/$adminId/edit': typeof NgosAdminNgoIdAdminIdEditRoute + '/ngos/admin/$ngoId/$adminId/view': typeof NgosAdminNgoIdAdminIdViewRoute } export interface FileRouteTypes { @@ -1077,7 +1128,6 @@ export interface FileRouteTypes { | '/monitoring-observers/$tab' | '/monitoring-observers/create-new-message' | '/monitoring-observers/import' - | '/ngos/$ngoId' | '/observer-guides/new' | '/observers/$observerId' | '/reset-password/success' @@ -1114,6 +1164,7 @@ export interface FileRouteTypes { | '/responses/incident-reports/$incidentReportId' | '/responses/quick-reports/$quickReportId' | '/election-rounds/$electionRoundId' + | '/ngos/edit/$ngoId' | '/citizen-report-attachments/$electionRoundId/$citizenReportId/$attachmentId' | '/election-rounds/$electionRoundId/locations/import' | '/election-rounds/$electionRoundId/polling-stations/import' @@ -1121,9 +1172,12 @@ export interface FileRouteTypes { | '/forms/$formId/edit-translation/$languageCode' | '/monitoring-observers/push-messages/$id/view' | '/monitoring-observers/view/$monitoringObserverId/$tab' + | '/ngos/view/$ngoId/$tab' | '/responses/citizen-reports/$formId/aggregated' | '/responses/form-submissions/$formId/aggregated' | '/responses/incident-reports/$formId/aggregated' + | '/ngos/admin/$ngoId/$adminId/edit' + | '/ngos/admin/$ngoId/$adminId/view' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -1138,7 +1192,6 @@ export interface FileRouteTypes { | '/monitoring-observers/$tab' | '/monitoring-observers/create-new-message' | '/monitoring-observers/import' - | '/ngos/$ngoId' | '/observer-guides/new' | '/observers/$observerId' | '/reset-password/success' @@ -1175,6 +1228,7 @@ export interface FileRouteTypes { | '/responses/incident-reports/$incidentReportId' | '/responses/quick-reports/$quickReportId' | '/election-rounds/$electionRoundId' + | '/ngos/edit/$ngoId' | '/citizen-report-attachments/$electionRoundId/$citizenReportId/$attachmentId' | '/election-rounds/$electionRoundId/locations/import' | '/election-rounds/$electionRoundId/polling-stations/import' @@ -1182,9 +1236,12 @@ export interface FileRouteTypes { | '/forms/$formId/edit-translation/$languageCode' | '/monitoring-observers/push-messages/$id/view' | '/monitoring-observers/view/$monitoringObserverId/$tab' + | '/ngos/view/$ngoId/$tab' | '/responses/citizen-reports/$formId/aggregated' | '/responses/form-submissions/$formId/aggregated' | '/responses/incident-reports/$formId/aggregated' + | '/ngos/admin/$ngoId/$adminId/edit' + | '/ngos/admin/$ngoId/$adminId/view' id: | '__root__' | '/' @@ -1199,7 +1256,6 @@ export interface FileRouteTypes { | '/monitoring-observers/$tab' | '/monitoring-observers/create-new-message' | '/monitoring-observers/import' - | '/ngos/$ngoId' | '/observer-guides/new' | '/observers/$observerId' | '/reset-password/success' @@ -1236,6 +1292,7 @@ export interface FileRouteTypes { | '/responses/incident-reports/$incidentReportId' | '/responses/quick-reports/$quickReportId' | '/election-rounds/$electionRoundId/' + | '/ngos/edit/$ngoId/' | '/citizen-report-attachments/$electionRoundId/$citizenReportId/$attachmentId' | '/election-rounds/$electionRoundId/locations/import' | '/election-rounds/$electionRoundId/polling-stations/import' @@ -1243,9 +1300,12 @@ export interface FileRouteTypes { | '/forms/$formId_/edit-translation/$languageCode' | '/monitoring-observers/push-messages/$id_/view' | '/monitoring-observers/view/$monitoringObserverId/$tab' + | '/ngos/view/$ngoId/$tab' | '/responses/citizen-reports/$formId/aggregated' | '/responses/form-submissions/$formId/aggregated' | '/responses/incident-reports/$formId/aggregated' + | '/ngos/admin/$ngoId/$adminId/edit' + | '/ngos/admin/$ngoId/$adminId/view' fileRoutesById: FileRoutesById } @@ -1262,7 +1322,6 @@ export interface RootRouteChildren { MonitoringObserversTabRoute: typeof MonitoringObserversTabRoute MonitoringObserversCreateNewMessageRoute: typeof MonitoringObserversCreateNewMessageRoute MonitoringObserversImportRoute: typeof MonitoringObserversImportRoute - NgosNgoIdRoute: typeof NgosNgoIdRoute ObserverGuidesNewRoute: typeof ObserverGuidesNewRoute ObserversObserverIdRoute: typeof ObserversObserverIdRoute ResetPasswordSuccessRoute: typeof ResetPasswordSuccessRoute @@ -1299,6 +1358,7 @@ export interface RootRouteChildren { ResponsesIncidentReportsIncidentReportIdRoute: typeof ResponsesIncidentReportsIncidentReportIdRoute ResponsesQuickReportsQuickReportIdRoute: typeof ResponsesQuickReportsQuickReportIdRoute ElectionRoundsElectionRoundIdIndexRoute: typeof ElectionRoundsElectionRoundIdIndexRoute + NgosEditNgoIdRoute: typeof NgosEditNgoIdRoute CitizenReportAttachmentsElectionRoundIdCitizenReportIdAttachmentIdRoute: typeof CitizenReportAttachmentsElectionRoundIdCitizenReportIdAttachmentIdRoute ElectionRoundsElectionRoundIdLocationsImportRoute: typeof ElectionRoundsElectionRoundIdLocationsImportRoute ElectionRoundsElectionRoundIdPollingStationsImportRoute: typeof ElectionRoundsElectionRoundIdPollingStationsImportRoute @@ -1306,9 +1366,12 @@ export interface RootRouteChildren { FormsFormIdEditTranslationLanguageCodeRoute: typeof FormsFormIdEditTranslationLanguageCodeRoute MonitoringObserversPushMessagesIdViewRoute: typeof MonitoringObserversPushMessagesIdViewRoute MonitoringObserversViewMonitoringObserverIdTabRoute: typeof MonitoringObserversViewMonitoringObserverIdTabRoute + NgosViewNgoIdTabRoute: typeof NgosViewNgoIdTabRoute ResponsesCitizenReportsFormIdAggregatedRoute: typeof ResponsesCitizenReportsFormIdAggregatedRoute ResponsesFormSubmissionsFormIdAggregatedRoute: typeof ResponsesFormSubmissionsFormIdAggregatedRoute ResponsesIncidentReportsFormIdAggregatedRoute: typeof ResponsesIncidentReportsFormIdAggregatedRoute + NgosAdminNgoIdAdminIdEditRoute: typeof NgosAdminNgoIdAdminIdEditRoute + NgosAdminNgoIdAdminIdViewRoute: typeof NgosAdminNgoIdAdminIdViewRoute } const rootRouteChildren: RootRouteChildren = { @@ -1325,7 +1388,6 @@ const rootRouteChildren: RootRouteChildren = { MonitoringObserversCreateNewMessageRoute: MonitoringObserversCreateNewMessageRoute, MonitoringObserversImportRoute: MonitoringObserversImportRoute, - NgosNgoIdRoute: NgosNgoIdRoute, ObserverGuidesNewRoute: ObserverGuidesNewRoute, ObserversObserverIdRoute: ObserversObserverIdRoute, ResetPasswordSuccessRoute: ResetPasswordSuccessRoute, @@ -1372,6 +1434,7 @@ const rootRouteChildren: RootRouteChildren = { ResponsesQuickReportsQuickReportIdRoute, ElectionRoundsElectionRoundIdIndexRoute: ElectionRoundsElectionRoundIdIndexRoute, + NgosEditNgoIdRoute: NgosEditNgoIdRoute, CitizenReportAttachmentsElectionRoundIdCitizenReportIdAttachmentIdRoute: CitizenReportAttachmentsElectionRoundIdCitizenReportIdAttachmentIdRoute, ElectionRoundsElectionRoundIdLocationsImportRoute: @@ -1386,12 +1449,15 @@ const rootRouteChildren: RootRouteChildren = { MonitoringObserversPushMessagesIdViewRoute, MonitoringObserversViewMonitoringObserverIdTabRoute: MonitoringObserversViewMonitoringObserverIdTabRoute, + NgosViewNgoIdTabRoute: NgosViewNgoIdTabRoute, ResponsesCitizenReportsFormIdAggregatedRoute: ResponsesCitizenReportsFormIdAggregatedRoute, ResponsesFormSubmissionsFormIdAggregatedRoute: ResponsesFormSubmissionsFormIdAggregatedRoute, ResponsesIncidentReportsFormIdAggregatedRoute: ResponsesIncidentReportsFormIdAggregatedRoute, + NgosAdminNgoIdAdminIdEditRoute: NgosAdminNgoIdAdminIdEditRoute, + NgosAdminNgoIdAdminIdViewRoute: NgosAdminNgoIdAdminIdViewRoute, } export const routeTree = rootRoute @@ -1416,7 +1482,6 @@ export const routeTree = rootRoute "/monitoring-observers/$tab", "/monitoring-observers/create-new-message", "/monitoring-observers/import", - "/ngos/$ngoId", "/observer-guides/new", "/observers/$observerId", "/reset-password/success", @@ -1453,6 +1518,7 @@ export const routeTree = rootRoute "/responses/incident-reports/$incidentReportId", "/responses/quick-reports/$quickReportId", "/election-rounds/$electionRoundId/", + "/ngos/edit/$ngoId/", "/citizen-report-attachments/$electionRoundId/$citizenReportId/$attachmentId", "/election-rounds/$electionRoundId/locations/import", "/election-rounds/$electionRoundId/polling-stations/import", @@ -1460,9 +1526,12 @@ export const routeTree = rootRoute "/forms/$formId_/edit-translation/$languageCode", "/monitoring-observers/push-messages/$id_/view", "/monitoring-observers/view/$monitoringObserverId/$tab", + "/ngos/view/$ngoId/$tab", "/responses/citizen-reports/$formId/aggregated", "/responses/form-submissions/$formId/aggregated", - "/responses/incident-reports/$formId/aggregated" + "/responses/incident-reports/$formId/aggregated", + "/ngos/admin/$ngoId/$adminId/edit", + "/ngos/admin/$ngoId/$adminId/view" ] }, "/": { @@ -1501,9 +1570,6 @@ export const routeTree = rootRoute "/monitoring-observers/import": { "filePath": "monitoring-observers/import.tsx" }, - "/ngos/$ngoId": { - "filePath": "ngos/$ngoId.tsx" - }, "/observer-guides/new": { "filePath": "observer-guides/new.tsx" }, @@ -1612,6 +1678,9 @@ export const routeTree = rootRoute "/election-rounds/$electionRoundId/": { "filePath": "election-rounds/$electionRoundId/index.tsx" }, + "/ngos/edit/$ngoId/": { + "filePath": "ngos/edit.$ngoId..tsx" + }, "/citizen-report-attachments/$electionRoundId/$citizenReportId/$attachmentId": { "filePath": "citizen-report-attachments/$electionRoundId.$citizenReportId.$attachmentId.tsx" }, @@ -1633,6 +1702,9 @@ export const routeTree = rootRoute "/monitoring-observers/view/$monitoringObserverId/$tab": { "filePath": "monitoring-observers/view/$monitoringObserverId.$tab.tsx" }, + "/ngos/view/$ngoId/$tab": { + "filePath": "ngos/view.$ngoId.$tab.tsx" + }, "/responses/citizen-reports/$formId/aggregated": { "filePath": "responses/citizen-reports/$formId.aggregated.tsx" }, @@ -1641,6 +1713,12 @@ export const routeTree = rootRoute }, "/responses/incident-reports/$formId/aggregated": { "filePath": "responses/incident-reports/$formId.aggregated.tsx" + }, + "/ngos/admin/$ngoId/$adminId/edit": { + "filePath": "ngos/admin/$ngoId.$adminId.edit.tsx" + }, + "/ngos/admin/$ngoId/$adminId/view": { + "filePath": "ngos/admin/$ngoId.$adminId.view.tsx" } } } diff --git a/web/src/routes/ngos/$ngoId.tsx b/web/src/routes/ngos/$ngoId.tsx deleted file mode 100644 index 019bfa402..000000000 --- a/web/src/routes/ngos/$ngoId.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { authApi } from '@/common/auth-api'; -import { NGO } from '@/features/ngos/models/NGO'; -import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; -import { redirectIfNotAuth } from '@/lib/utils'; - -export const ngoQueryOptions = (ngoId: string) => - queryOptions({ - queryKey: ['ngos', { ngoId }], - queryFn: async () => { - const response = await authApi.get(`/ngos/${ngoId}`); - - if (response.status !== 200) { - throw new Error('Failed to fetch ngo details'); - } - - return response.data; - }, - }); - -export const Route = createFileRoute('/ngos/$ngoId')({ - beforeLoad: () => { - redirectIfNotAuth(); - }, - component: NgoDetails, - loader: ({ context: { queryClient }, params: { ngoId } }) => queryClient.ensureQueryData(ngoQueryOptions(ngoId)), -}); - -function NgoDetails() { - const { ngoId } = Route.useParams(); - const { data: ngo } = useSuspenseQuery(ngoQueryOptions(ngoId)); - - return
Hello from ngos! {JSON.stringify(ngo, null, 2)}
; -} diff --git a/web/src/routes/ngos/admin/$ngoId.$adminId.edit.tsx b/web/src/routes/ngos/admin/$ngoId.$adminId.edit.tsx new file mode 100644 index 000000000..f9dd8f57f --- /dev/null +++ b/web/src/routes/ngos/admin/$ngoId.$adminId.edit.tsx @@ -0,0 +1,29 @@ +import { EditNgoAdmin } from '@/features/ngos/components/admins/EditNgoAdmin'; +import { ngoAdminDetailsOptions, useNgoAdminDetails } from '@/features/ngos/hooks/ngo-admin-queries'; +import { ngoDetailsOptions } from '@/features/ngos/hooks/ngos-queries'; +import { redirectIfNotAuth, redirectIfNotPlatformAdmin } from '@/lib/utils'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/ngos/admin/$ngoId/$adminId/edit')({ + beforeLoad: ({ params }) => { + redirectIfNotAuth(); + redirectIfNotPlatformAdmin(); + }, + component: NgoAdminDetails, + + loader: async ({ context: { queryClient }, params: { ngoId, adminId } }) => { + const ngoDataPromise = queryClient.ensureQueryData(ngoDetailsOptions(ngoId)); + const ngoAdminDataPromise = queryClient.ensureQueryData(ngoAdminDetailsOptions({ ngoId, adminId })); + + const [ngoData, ngoAdminData] = await Promise.all([ngoDataPromise, ngoAdminDataPromise]); + + return { ngoAdminData, ngoData }; + }, +}); + +function NgoAdminDetails() { + const { ngoId, adminId } = Route.useParams(); + const { data: ngoAdmin } = useNgoAdminDetails({ ngoId, adminId }); + + return ; +} diff --git a/web/src/routes/ngos/admin/$ngoId.$adminId.view.tsx b/web/src/routes/ngos/admin/$ngoId.$adminId.view.tsx new file mode 100644 index 000000000..b6ddb36e1 --- /dev/null +++ b/web/src/routes/ngos/admin/$ngoId.$adminId.view.tsx @@ -0,0 +1,29 @@ +import { NgoAdminDetailsView } from '@/features/ngos/components/admins/NgoAdminDetailsView'; +import { ngoAdminDetailsOptions, useNgoAdminDetails } from '@/features/ngos/hooks/ngo-admin-queries'; +import { ngoDetailsOptions } from '@/features/ngos/hooks/ngos-queries'; +import { redirectIfNotAuth, redirectIfNotPlatformAdmin } from '@/lib/utils'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/ngos/admin/$ngoId/$adminId/view')({ + beforeLoad: ({ params }) => { + redirectIfNotAuth(); + redirectIfNotPlatformAdmin(); + }, + component: NgoAdminDetails, + + loader: async ({ context: { queryClient }, params: { ngoId, adminId } }) => { + const ngoDataPromise = queryClient.ensureQueryData(ngoDetailsOptions(ngoId)); + const ngoAdminDataPromise = queryClient.ensureQueryData(ngoAdminDetailsOptions({ ngoId, adminId })); + + const [ngoData, ngoAdminData] = await Promise.all([ngoDataPromise, ngoAdminDataPromise]); + + return { ngoAdminData, ngoData }; + }, +}); + +function NgoAdminDetails() { + const { ngoId, adminId } = Route.useParams(); + const { data: ngoAdmin } = useNgoAdminDetails({ ngoId, adminId }); + + return ; +} diff --git a/web/src/routes/ngos/edit.$ngoId..tsx b/web/src/routes/ngos/edit.$ngoId..tsx new file mode 100644 index 000000000..21ede6a09 --- /dev/null +++ b/web/src/routes/ngos/edit.$ngoId..tsx @@ -0,0 +1,21 @@ +import { EditNgo } from '@/features/ngos/components/EditNgo'; +import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queries'; +import { redirectIfNotAuth, redirectIfNotPlatformAdmin } from '@/lib/utils'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/ngos/edit/$ngoId/')({ + beforeLoad: ({ params }) => { + redirectIfNotAuth(); + redirectIfNotPlatformAdmin(); + }, + component: EditNgoPage, + + loader: ({ context: { queryClient }, params: { ngoId } }) => queryClient.ensureQueryData(ngoDetailsOptions(ngoId)), +}); + +function EditNgoPage() { + const { ngoId } = Route.useParams(); + const { data: ngo } = useNGODetails(ngoId); + + return ; +} diff --git a/web/src/routes/ngos/index.tsx b/web/src/routes/ngos/index.tsx index bee746bde..34a312b5d 100644 --- a/web/src/routes/ngos/index.tsx +++ b/web/src/routes/ngos/index.tsx @@ -1,23 +1,22 @@ -import { SortOrder } from '@/common/types'; +import { DefaultSearchParamsSchema } from '@/common/zod-schemas'; import NGOsDashboard from '@/features/ngos/components/Dashboard/Dashboard'; -import { redirectIfNotAuth } from '@/lib/utils'; -import { createFileRoute } from '@tanstack/react-router'; +import { NGOStatus } from '@/features/ngos/models/NGO'; +import { redirectIfNotAuth, redirectIfNotPlatformAdmin } from '@/lib/utils'; +import { createFileRoute, SearchSchemaInput } from '@tanstack/react-router'; import { z } from 'zod'; -const ngoRouteSearchSchema = z.object({ - nameFilter: z.string().catch(''), - pageNumber: z.number().catch(1), - pageSize: z.number().catch(10), - sortColumnName: z.string().catch(''), - sortOrder: z.enum([SortOrder.asc, SortOrder.desc]).catch(SortOrder.asc), +const NgosAdditionalSearchParams = z.object({ + status: z.nativeEnum(NGOStatus).optional(), }); +export const ngoRouteSearchSchema = NgosAdditionalSearchParams.merge(DefaultSearchParamsSchema); + export const Route = createFileRoute('/ngos/')({ beforeLoad: () => { - redirectIfNotAuth(); + redirectIfNotPlatformAdmin(); }, component: Ngos, - validateSearch: ngoRouteSearchSchema, + validateSearch: (search: unknown & SearchSchemaInput) => ngoRouteSearchSchema.parse(search), }); function Ngos() { diff --git a/web/src/routes/ngos/view.$ngoId.$tab.tsx b/web/src/routes/ngos/view.$ngoId.$tab.tsx new file mode 100644 index 000000000..5e0502390 --- /dev/null +++ b/web/src/routes/ngos/view.$ngoId.$tab.tsx @@ -0,0 +1,47 @@ +import { NGODetails } from '@/features/ngos/components/NGODetails'; +import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queries'; +import { redirectIfNotPlatformAdmin } from '@/lib/utils'; +import { createFileRoute, redirect } from '@tanstack/react-router'; +import { z } from 'zod'; +import { ngoRouteSearchSchema } from '.'; + +export const ngoAdminsSearchParamsSchema = ngoRouteSearchSchema.partial(); +export type NgoAdminsSearchParams = z.infer; + +export const NgosDetailsdPageSearchParamsSchema = ngoAdminsSearchParamsSchema.merge( + z.object({ + tab: z.enum(['details', 'admins']).catch('details').optional(), + }) +); + +export const Route = createFileRoute('/ngos/view/$ngoId/$tab')({ + beforeLoad: ({ params }) => { + redirectIfNotPlatformAdmin(); + + const coercedTab = coerceTabSlug(params.tab); + if (params.tab !== coercedTab) { + throw redirect({ + to: `/ngos/view/$ngoId/$tab`, + params: { tab: coercedTab, ngoId: params.ngoId }, + }); + } + }, + component: NgoDetails, + validateSearch: NgosDetailsdPageSearchParamsSchema, + + loader: ({ context: { queryClient }, params: { ngoId } }) => queryClient.ensureQueryData(ngoDetailsOptions(ngoId)), +}); + +const coerceTabSlug = (slug: string) => { + if (slug?.toLowerCase()?.trim() === 'details') return 'details'; + if (slug?.toLowerCase()?.trim() === 'admins') return 'admins'; + + return 'details'; +}; + +function NgoDetails() { + const { ngoId } = Route.useParams(); + const { data: ngo } = useNGODetails(ngoId); + + return ; +} diff --git a/web/src/styles/tailwind.css b/web/src/styles/tailwind.css index f98f3aae7..0447c541d 100644 --- a/web/src/styles/tailwind.css +++ b/web/src/styles/tailwind.css @@ -66,11 +66,4 @@ .breadcrumbs .crumb:last-child:after { display: none; -} - -.election-text { - max-width: 350px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} +} \ No newline at end of file