diff --git a/app/(dashboard)/dashboard/_components/ParticipantSelectionDropdown.tsx b/app/(dashboard)/dashboard/_components/ParticipantSelectionDropdown.tsx new file mode 100644 index 00000000..300310d7 --- /dev/null +++ b/app/(dashboard)/dashboard/_components/ParticipantSelectionDropdown.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState } from 'react'; + +import { Button } from '~/components/ui/Button'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '~/components/ui/dropdown-menu'; +import type { Participant } from '@prisma/client'; +import { ScrollArea } from '~/components/ui/scroll-area'; + +type ParticipantSelectionDropdownProps = { + participants: Participant[]; + disabled: boolean; + setParticipantsToExport: (participants: Participant[]) => void; +}; + +export function ParticipantSelectionDropdown({ + participants, + disabled, + setParticipantsToExport, +}: ParticipantSelectionDropdownProps) { + const [selectedParticipants, setSelectedParticipants] = useState< + Participant[] + >([]); + + useEffect(() => { + setSelectedParticipants(participants); + }, [participants, setParticipantsToExport]); + + useEffect(() => { + setParticipantsToExport(selectedParticipants); + }, [selectedParticipants, setParticipantsToExport]); + + return ( + + + + + + + Participants +
+
+ {selectedParticipants.length} selected +
+ + +
+ + + + {/* loop through all participants and render a dropdown menu checkbox item for them */} + {participants.map((participant) => { + return ( + { + if (checked) { + setSelectedParticipants([ + ...selectedParticipants, + participant, + ]); + } else { + setSelectedParticipants( + selectedParticipants.filter( + (selectedParticipant) => + selectedParticipant !== participant, + ), + ); + } + }} + > + {participant.identifier} + + ); + })} +
+
+
+ ); +} diff --git a/app/(dashboard)/dashboard/_components/ParticipantsTable/ActionsDropdown.tsx b/app/(dashboard)/dashboard/_components/ParticipantsTable/ActionsDropdown.tsx index 1842a6b7..0307378a 100644 --- a/app/(dashboard)/dashboard/_components/ParticipantsTable/ActionsDropdown.tsx +++ b/app/(dashboard)/dashboard/_components/ParticipantsTable/ActionsDropdown.tsx @@ -10,7 +10,6 @@ import { DropdownMenuTrigger, } from '~/components/ui/dropdown-menu'; import type { Row } from '@tanstack/react-table'; -import CopyButton from './CopyButton'; import { useState } from 'react'; import ParticipantModal from '~/app/(dashboard)/dashboard/participants/_components/ParticipantModal'; import type { ParticipantWithInterviews } from '~/shared/types'; @@ -72,10 +71,6 @@ export const ActionsDropdown = ({ handleDelete(row.original)}> Delete - - - Copy URL - diff --git a/app/(dashboard)/dashboard/_components/ParticipantsTable/Columns.tsx b/app/(dashboard)/dashboard/_components/ParticipantsTable/Columns.tsx index 9dc8e59f..f03d785e 100644 --- a/app/(dashboard)/dashboard/_components/ParticipantsTable/Columns.tsx +++ b/app/(dashboard)/dashboard/_components/ParticipantsTable/Columns.tsx @@ -1,10 +1,10 @@ 'use client'; import { type ColumnDef } from '@tanstack/react-table'; -import Link from 'next/link'; import { DataTableColumnHeader } from '~/components/DataTable/ColumnHeader'; import { Checkbox } from '~/components/ui/checkbox'; import type { ParticipantWithInterviews } from '~/shared/types'; +import { GetParticipantURLButton } from './GetParticipantURLButton'; export const ParticipantColumns = (): ColumnDef[] => [ @@ -45,21 +45,9 @@ export const ParticipantColumns = }, }, { - accessorKey: 'Unique_interview_URL', - header: ({ column }) => { - return ( - - ); + id: 'participant-url', + cell: ({ row }) => { + return ; }, - cell: ({ row }) => ( - - Participant link - - ), - enableSorting: false, - enableHiding: false, }, ]; diff --git a/app/(dashboard)/dashboard/_components/ParticipantsTable/GetParticipantURLButton.tsx b/app/(dashboard)/dashboard/_components/ParticipantsTable/GetParticipantURLButton.tsx new file mode 100644 index 00000000..998c523e --- /dev/null +++ b/app/(dashboard)/dashboard/_components/ParticipantsTable/GetParticipantURLButton.tsx @@ -0,0 +1,113 @@ +'use client'; +import type { Participant, Protocol } from '@prisma/client'; +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '~/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '~/components/ui/select'; + +import { Button } from '~/components/ui/Button'; +import { api } from '~/trpc/client'; +import { getBaseUrl } from '~/trpc/shared'; +import { useToast } from '~/components/ui/use-toast'; + +export const GetParticipantURLButton = ({ + participant, +}: { + participant: Participant; +}) => { + const { data: protocolData, isLoading: isLoadingProtocols } = + api.protocol.get.all.useQuery(); + const [protocols, setProtocols] = useState([]); + const [selectedProtocol, setSelectedProtocol] = useState(); + const [dialogOpen, setDialogOpen] = useState(false); + + useEffect(() => { + if (protocolData) { + setProtocols(protocolData); + } + }, [protocolData]); + + const { toast } = useToast(); + + const handleCopy = (url: string) => { + if (url) { + navigator.clipboard + .writeText(url) + .then(() => { + toast({ + description: 'Copied to clipboard', + variant: 'success', + duration: 3000, + }); + }) + .catch(() => { + toast({ + title: 'Error', + description: 'Could not copy text', + variant: 'destructive', + }); + }); + } + setDialogOpen(false); + }; + + return ( + + setDialogOpen(true)}> + + + + + Get Participation URL + + Generate a URL that can be shared with a participant to allow them + to participate for a selected protocol. + + + <> +
+ +
+ +
+
+ ); +}; diff --git a/app/(dashboard)/dashboard/_components/ParticipantsTable/ParticipantsTable.tsx b/app/(dashboard)/dashboard/_components/ParticipantsTable/ParticipantsTable.tsx index abe3a748..ec936012 100644 --- a/app/(dashboard)/dashboard/_components/ParticipantsTable/ParticipantsTable.tsx +++ b/app/(dashboard)/dashboard/_components/ParticipantsTable/ParticipantsTable.tsx @@ -4,14 +4,14 @@ import { api } from '~/trpc/client'; import { DataTable } from '~/components/DataTable/DataTable'; import { ParticipantColumns } from '~/app/(dashboard)/dashboard/_components/ParticipantsTable/Columns'; import ImportCSVModal from '~/app/(dashboard)/dashboard/participants/_components/ImportCSVModal'; -import ExportCSVParticipants from '~/app/(dashboard)/dashboard/participants/_components/ExportCSVParticipants'; import type { ParticipantWithInterviews } from '~/shared/types'; import { ActionsDropdown } from '~/app/(dashboard)/dashboard/_components/ParticipantsTable/ActionsDropdown'; import { DeleteAllParticipantsButton } from '~/app/(dashboard)/dashboard/participants/_components/DeleteAllParticipantsButton'; import AddParticipantButton from '~/app/(dashboard)/dashboard/participants/_components/AddParticipantButton'; import { useState } from 'react'; import { DeleteParticipantsDialog } from '~/app/(dashboard)/dashboard/participants/_components/DeleteParticipantsDialog'; - +import { ExportParticipantUrlSection } from '~/app/(dashboard)/dashboard/participants/_components/ExportParticipantUrlSection'; +import ExportParticipants from '~/app/(dashboard)/dashboard/participants/_components/ExportParticipants'; export const ParticipantsTable = ({ initialData, }: { @@ -42,9 +42,10 @@ export const ParticipantsTable = ({
- +
+ {isLoading &&
Loading...
} { + return ( +
+
+

Anonymous Recruitment

+

+ If anonymous recruitment is enabled, you may generate an anonymous + participation URL. This URL can be shared with participants to allow + them to self-enroll in your study. +

+
+ +
+

Allow anonymous recruitment?

+ +
+ +
+ ); +}; diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/AnonymousRecruitmentURLButton.tsx b/app/(dashboard)/dashboard/_components/ProtocolsTable/AnonymousRecruitmentURLButton.tsx new file mode 100644 index 00000000..c041d010 --- /dev/null +++ b/app/(dashboard)/dashboard/_components/ProtocolsTable/AnonymousRecruitmentURLButton.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { Badge } from '~/components/ui/badge'; +import { getBaseUrl } from '~/trpc/shared'; +import { useToast } from '~/components/ui/use-toast'; +import { Copy } from 'lucide-react'; +import { api } from '~/trpc/client'; + +export const AnonymousRecruitmentURLButton = ({ + protocolId, +}: { + protocolId: string; +}) => { + const { data: appSettings } = api.appSettings.get.useQuery(); + const allowAnonymousRecruitment = !!appSettings?.allowAnonymousRecruitment; + + const { toast } = useToast(); + const url = `${getBaseUrl()}/onboard/${protocolId}`; + const handleCopyClick = () => { + navigator.clipboard + .writeText(url) + .then(() => { + toast({ + description: 'Copied to clipboard', + variant: 'success', + duration: 3000, + }); + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error('Could not copy text: ', error); + toast({ + title: 'Error', + description: 'Could not copy text', + variant: 'destructive', + }); + }); + }; + + return ( + <> + {allowAnonymousRecruitment ? ( + +

{url}

+ +
+ ) : ( + Disabled + )} + + ); +}; diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx b/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx index d6b0416d..b35d9ab5 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx @@ -7,6 +7,7 @@ import { Checkbox } from '~/components/ui/checkbox'; import { DataTableColumnHeader } from '~/components/DataTable/ColumnHeader'; import type { ProtocolWithInterviews } from '~/shared/types'; import { dateOptions } from '~/components/DataTable/helpers'; +import { AnonymousRecruitmentURLButton } from './AnonymousRecruitmentURLButton'; export const ProtocolColumns: ColumnDef[] = [ { @@ -86,4 +87,11 @@ export const ProtocolColumns: ColumnDef[] = [ ), }, + { + id: 'participant-url', + header: 'Anonymous Participation URL', + cell: ({ row }) => { + return ; + }, + }, ]; diff --git a/app/(dashboard)/dashboard/_components/ParticipantsTable/CopyButton.tsx b/app/(dashboard)/dashboard/_components/ProtocolsTable/CopyButton.tsx similarity index 68% rename from app/(dashboard)/dashboard/_components/ParticipantsTable/CopyButton.tsx rename to app/(dashboard)/dashboard/_components/ProtocolsTable/CopyButton.tsx index 0e573215..10a1c856 100644 --- a/app/(dashboard)/dashboard/_components/ParticipantsTable/CopyButton.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolsTable/CopyButton.tsx @@ -1,20 +1,18 @@ +import { Copy } from 'lucide-react'; import type { FC } from 'react'; -import { DropdownMenuItem } from '~/components/ui/dropdown-menu'; import { useToast } from '~/components/ui/use-toast'; type CopyButtonProps = { text: string; - children: React.ReactNode; }; -const CopyButton: FC = ({ text, children }) => { +const CopyButton: FC = ({ text }) => { const { toast } = useToast(); const handleCopyClick = () => { - const copyText = text; - if (copyText) { + if (text) { navigator.clipboard - .writeText(copyText) + .writeText(text) .then(() => { toast({ description: 'Copied to clipboard', @@ -34,9 +32,7 @@ const CopyButton: FC = ({ text, children }) => { } }; - return ( - {children} - ); + return ; }; export default CopyButton; diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx b/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx index e642b04e..eee95b2b 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx @@ -7,6 +7,8 @@ import { api } from '~/trpc/client'; import { DeleteProtocolsDialog } from '~/app/(dashboard)/dashboard/protocols/_components/DeleteProtocolsDialog'; import { useState } from 'react'; import type { ProtocolWithInterviews } from '~/shared/types'; +import { AnonymousRecruitmentSection } from './AnonymousRecruitmentSection'; +import { ParticipationUrlModal } from '~/app/(dashboard)/dashboard/protocols/_components/ParticipationUrlModal'; export const ProtocolsTable = ({ initialData, @@ -35,6 +37,10 @@ export const ProtocolsTable = ({ return ( <> +
+ + +
{isLoading &&
Loading...
} columns={ProtocolColumns} diff --git a/app/(dashboard)/dashboard/participants/_components/ExportCSVParticipantURLs.tsx b/app/(dashboard)/dashboard/participants/_components/ExportCSVParticipantURLs.tsx new file mode 100644 index 00000000..a641fbaa --- /dev/null +++ b/app/(dashboard)/dashboard/participants/_components/ExportCSVParticipantURLs.tsx @@ -0,0 +1,76 @@ +'use client'; + +import type { Protocol, Participant } from '@prisma/client'; +import { Download } from 'lucide-react'; +import { unparse } from 'papaparse'; +import { useState } from 'react'; +import { Button } from '~/components/ui/Button'; +import { useToast } from '~/components/ui/use-toast'; +import { useDownload } from '~/hooks/useDownload'; +import { getBaseUrl } from '~/trpc/shared'; + +function ExportCSVParticipantURLs({ + participants, + protocol, + disabled, +}: { + participants: Participant[] | undefined; + protocol: Protocol | undefined; + disabled: boolean; +}) { + const download = useDownload(); + const [isExporting, setIsExporting] = useState(false); + const { toast } = useToast(); + + const handleExport = () => { + try { + setIsExporting(true); + if (!participants) return; + if (!protocol?.id) return; + + // CSV file format + const csvData = participants.map((participant) => ({ + id: participant.id, + identifier: participant.identifier, + interview_url: `${getBaseUrl()}/onboard/${protocol.id}/?participantId=${ + participant.id + }`, + })); + + const csv = unparse(csvData, { header: true }); + + // Create a download link + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + // trigger the download + const protocolNameWithoutExtension = protocol.name.split('.')[0]; + const fileName = `participation_urls_${protocolNameWithoutExtension}.csv`; + download(url, fileName); + // Clean up the URL object + URL.revokeObjectURL(url); + toast({ + description: 'Participation URLs CSV exported successfully', + variant: 'success', + duration: 3000, + }); + } catch (error) { + toast({ + title: 'Error', + description: 'An error occurred while exporting participation URLs', + variant: 'destructive', + }); + throw new Error('An error occurred while exporting participation URLs'); + } + + setIsExporting(false); + }; + + return ( + + ); +} + +export default ExportCSVParticipantURLs; diff --git a/app/(dashboard)/dashboard/participants/_components/ExportParticipantUrlSection.tsx b/app/(dashboard)/dashboard/participants/_components/ExportParticipantUrlSection.tsx new file mode 100644 index 00000000..275d8506 --- /dev/null +++ b/app/(dashboard)/dashboard/participants/_components/ExportParticipantUrlSection.tsx @@ -0,0 +1,80 @@ +'use client'; +import type { Participant, Protocol } from '@prisma/client'; +import { useState, useEffect } from 'react'; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '~/components/ui/select'; + +import { api } from '~/trpc/client'; +import ExportCSVParticipantURLs from '~/app/(dashboard)/dashboard/participants/_components/ExportCSVParticipantURLs'; + +export const ExportParticipantUrlSection = () => { + const { data: protocolData, isLoading: isLoadingProtocols } = + api.protocol.get.all.useQuery(); + const [protocols, setProtocols] = useState([]); + const [participants, setParticipants] = useState([]); + + const [selectedProtocol, setSelectedProtocol] = useState(); + + const { data: participantData, isLoading: isLoadingParticipants } = + api.participant.get.all.useQuery(); + + useEffect(() => { + if (protocolData) { + setProtocols(protocolData); + } + }, [protocolData]); + + useEffect(() => { + if (participantData) { + setParticipants(participantData); + } + }, [participantData]); + + return ( +
+

Generate Participation URLs

+

+ Generate a CSV of participation URLs for all participants by protocol. + These URLs can be shared with participants to allow them to participate + in your study. +

+
+ {/* Protocol selection */} + + + +
+
+ ); +}; diff --git a/app/(dashboard)/dashboard/participants/_components/ExportCSVParticipants.tsx b/app/(dashboard)/dashboard/participants/_components/ExportParticipants.tsx similarity index 68% rename from app/(dashboard)/dashboard/participants/_components/ExportCSVParticipants.tsx rename to app/(dashboard)/dashboard/participants/_components/ExportParticipants.tsx index 9750330a..56ca65ae 100644 --- a/app/(dashboard)/dashboard/participants/_components/ExportCSVParticipants.tsx +++ b/app/(dashboard)/dashboard/participants/_components/ExportParticipants.tsx @@ -1,18 +1,21 @@ 'use client'; import { type Participant } from '@prisma/client'; +import { Download } from 'lucide-react'; import { unparse } from 'papaparse'; import { useState } from 'react'; import { Button } from '~/components/ui/Button'; +import { useToast } from '~/components/ui/use-toast'; import { useDownload } from '~/hooks/useDownload'; -function ExportCSVParticipants({ +function ExportParticipants({ participants, }: { - participants: Participant[]; + participants: Participant[] | undefined; }) { const download = useDownload(); const [isExporting, setIsExporting] = useState(false); + const { toast } = useToast(); const handleExport = () => { try { @@ -23,7 +26,6 @@ function ExportCSVParticipants({ const csvData = participants.map((participant) => ({ id: participant.id, identifier: participant.identifier, - interview_url: `interview/${participant.id}`, })); const csv = unparse(csvData, { header: true }); @@ -35,7 +37,17 @@ function ExportCSVParticipants({ download(url, 'participants.csv'); // Clean up the URL object URL.revokeObjectURL(url); + toast({ + description: 'Participant CSV exported successfully', + variant: 'success', + duration: 3000, + }); } catch (error) { + toast({ + title: 'Error', + description: 'An error occurred while exporting participants', + variant: 'destructive', + }); throw new Error('An error occurred while exporting participants'); } @@ -44,9 +56,10 @@ function ExportCSVParticipants({ return ( ); } -export default ExportCSVParticipants; +export default ExportParticipants; diff --git a/app/(dashboard)/dashboard/protocols/_components/AnonymousRecruitmentModal.tsx b/app/(dashboard)/dashboard/protocols/_components/AnonymousRecruitmentModal.tsx new file mode 100644 index 00000000..476a0375 --- /dev/null +++ b/app/(dashboard)/dashboard/protocols/_components/AnonymousRecruitmentModal.tsx @@ -0,0 +1,97 @@ +'use client'; +import type { Protocol } from '@prisma/client'; +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '~/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '~/components/ui/select'; + +import { Button } from '~/components/ui/Button'; +import { api } from '~/trpc/client'; +import { getBaseUrl } from '~/trpc/shared'; +import CopyButton from '~/app/(dashboard)/dashboard/_components/ProtocolsTable/CopyButton'; + +export const AnonymousRecruitmentModal = () => { + const { data: protocolData, isLoading: isLoadingProtocols } = + api.protocol.get.all.useQuery(); + const [protocols, setProtocols] = useState([]); + + const [selectedProtocol, setSelectedProtocol] = useState(); + + const { data: appSettings } = api.appSettings.get.useQuery(); + + const allowAnonymousRecruitment = !!appSettings?.allowAnonymousRecruitment; + + useEffect(() => { + if (protocolData) { + setProtocols(protocolData); + } + }, [protocolData]); + + const url = `${getBaseUrl()}/onboard/${selectedProtocol?.id}`; + + return ( + setSelectedProtocol(undefined)}> + + + + + + Generate Anonymous Participation URL + + Generate an anonymous participation URL for a protocol. This URL can + be shared with participants to allow them to self-enroll in your + study. + + + <> +
+ +
+ {selectedProtocol && ( +
+
{url}
+ +
+ )} + +
+
+ ); +}; diff --git a/app/(dashboard)/dashboard/protocols/_components/ParticipationUrlModal.tsx b/app/(dashboard)/dashboard/protocols/_components/ParticipationUrlModal.tsx new file mode 100644 index 00000000..1657728d --- /dev/null +++ b/app/(dashboard)/dashboard/protocols/_components/ParticipationUrlModal.tsx @@ -0,0 +1,103 @@ +'use client'; +import type { Participant, Protocol } from '@prisma/client'; +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '~/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '~/components/ui/select'; + +import { Button } from '~/components/ui/Button'; +import { api } from '~/trpc/client'; +import ExportCSVParticipants from '~/app/(dashboard)/dashboard/participants/_components/ExportCSVParticipantURLs'; +import { ParticipantSelectionDropdown } from '~/app/(dashboard)/dashboard/_components/ParticipantSelectionDropdown'; + +export const ParticipationUrlModal = () => { + const { data: protocolData, isLoading: isLoadingProtocols } = + api.protocol.get.all.useQuery(); + const [protocols, setProtocols] = useState([]); + const [participants, setParticipants] = useState([]); + const [participantsToExport, setParticipantsToExport] = useState< + Participant[] | undefined + >([]); + const [selectedProtocol, setSelectedProtocol] = useState(); + + const { data: participantData, isLoading: isLoadingParticipants } = + api.participant.get.all.useQuery(); + + useEffect(() => { + if (protocolData) { + setProtocols(protocolData); + } + }, [protocolData]); + + useEffect(() => { + if (participantData) { + setParticipants(participantData); + } + }, [participantData]); + + return ( + setSelectedProtocol(undefined)}> + + + + + + Export Participation URLs + + Generate a CSV of participation URLs for selected participants by + protocol. + + +
+ {/* Protocol selection */} + + + + + +
+
+
+ ); +};