diff --git a/package.json b/package.json index 22a166c..67844e6 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --turbopack", "build": "next build", "start": "next start", "lint": "next lint", @@ -18,10 +18,12 @@ "dependencies": { "@clerk/nextjs": "^6.4.0", "@radix-ui/react-alert-dialog": "^1.1.2", + "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.1.1", "@types/nodemailer": "^6.4.17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c33c20..6d765f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@radix-ui/react-alert-dialog': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-avatar': + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -26,6 +29,9 @@ importers: '@radix-ui/react-select': specifier: ^2.1.2 version: 2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-separator': + specifier: ^1.1.0 + version: 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': specifier: ^1.1.0 version: 1.1.0(@types/react@18.3.12)(react@18.3.1) @@ -889,6 +895,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-avatar@1.1.1': + resolution: {integrity: sha512-eoOtThOmxeoizxpX6RiEsQZ2wj5r4+zoeqAwO0cBaFQGjJwIH3dIX0OCxNrCyrrdxG+vBweMETh3VziQG7c1kw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.0': resolution: {integrity: sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==} peerDependencies: @@ -1091,6 +1110,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-separator@1.1.0': + resolution: {integrity: sha512-3uBAs+egzvJBDZAzvb/n4NxxOYpnspmWxO2u5NbZ8Y6FM/NdrGSF9bop3Cf6F6C71z1rTSn8KV0Fo2ZVd79lGA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.1.0': resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==} peerDependencies: @@ -3610,6 +3642,18 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-avatar@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-collection@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) @@ -3811,6 +3855,15 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-separator@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-slot@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) diff --git a/src/app/api/studies/[id]/participants/route.ts b/src/app/api/studies/[id]/participants/route.ts index c24dc8b..fb7b7bf 100644 --- a/src/app/api/studies/[id]/participants/route.ts +++ b/src/app/api/studies/[id]/participants/route.ts @@ -7,16 +7,17 @@ import { auth } from "@clerk/nextjs/server"; export async function GET( request: Request, - { params }: { params: { id: string } } + context: { params: { id: string } } ) { const { userId } = await auth(); + const { id } = await context.params; if (!userId) { return ApiError.Unauthorized(); } try { - const studyId = parseInt(params.id); + const studyId = parseInt(id); if (isNaN(studyId)) { return ApiError.BadRequest("Invalid study ID"); @@ -27,15 +28,19 @@ export async function GET( permission: PERMISSIONS.VIEW_PARTICIPANT_NAMES, }); - if (permissionCheck.error) { - return permissionCheck.error; - } - const participants = await db .select() .from(participantsTable) .where(eq(participantsTable.studyId, studyId)); + if (permissionCheck.error) { + const anonymizedParticipants = participants.map((participant, index) => ({ + ...participant, + name: `Participant ${String.fromCharCode(65 + index)}`, + })); + return createApiResponse(anonymizedParticipants); + } + return createApiResponse(participants); } catch (error) { return ApiError.ServerError(error); @@ -44,16 +49,17 @@ export async function GET( export async function POST( request: Request, - { params }: { params: { id: string } } + context: { params: { id: string } } ) { const { userId } = await auth(); + const { id } = await context.params; if (!userId) { return ApiError.Unauthorized(); } try { - const studyId = parseInt(params.id); + const studyId = parseInt(id); const { name } = await request.json(); if (isNaN(studyId)) { @@ -89,16 +95,17 @@ export async function POST( export async function DELETE( request: Request, - { params }: { params: { id: string } } + context: { params: { id: string } } ) { const { userId } = await auth(); + const { id } = await context.params; if (!userId) { return ApiError.Unauthorized(); } try { - const studyId = parseInt(params.id); + const studyId = parseInt(id); const { participantId } = await request.json(); if (isNaN(studyId)) { diff --git a/src/app/api/studies/[id]/route.ts b/src/app/api/studies/[id]/route.ts index 3b69424..d42a471 100644 --- a/src/app/api/studies/[id]/route.ts +++ b/src/app/api/studies/[id]/route.ts @@ -6,7 +6,7 @@ import { ApiError, createApiResponse } from "~/lib/api-utils"; export async function GET(request: Request, { params }: { params: { id: string } }) { try { - const { id } = params; + const id = await Promise.resolve(params.id); const studyId = parseInt(id); if (isNaN(studyId)) { diff --git a/src/app/api/studies/[id]/stats/route.ts b/src/app/api/studies/[id]/stats/route.ts new file mode 100644 index 0000000..d9af873 --- /dev/null +++ b/src/app/api/studies/[id]/stats/route.ts @@ -0,0 +1,55 @@ +import { eq } from "drizzle-orm"; +import { sql } from "drizzle-orm"; +import { db } from "~/db"; +import { participantsTable } from "~/db/schema"; +import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server"; +import { ApiError, createApiResponse } from "~/lib/api-utils"; +import { auth } from "@clerk/nextjs/server"; + +export async function GET( + request: Request, + context: { params: { id: string } } +) { + const { userId } = await auth(); + const { id } = context.params; + + if (!userId) { + return ApiError.Unauthorized(); + } + + try { + const studyId = parseInt(id); + + if (isNaN(studyId)) { + return ApiError.BadRequest("Invalid study ID"); + } + + const permissionCheck = await checkPermissions({ + studyId, + permission: PERMISSIONS.VIEW_STUDY, + }); + + if (permissionCheck.error) { + return permissionCheck.error; + } + + // Get participant count using SQL count + const [{ count }] = await db + .select({ + count: sql`count(*)::int`, + }) + .from(participantsTable) + .where(eq(participantsTable.studyId, studyId)); + + // TODO: Add actual trial and form counts when those tables are added + const stats = { + participantCount: count, + completedTrialsCount: 0, + pendingFormsCount: 0, + }; + + return createApiResponse(stats); + } catch (error) { + return ApiError.ServerError(error); + } +} \ No newline at end of file diff --git a/src/app/api/studies/[id]/users/[userId]/role/route.ts b/src/app/api/studies/[id]/users/[userId]/role/route.ts new file mode 100644 index 0000000..533c695 --- /dev/null +++ b/src/app/api/studies/[id]/users/[userId]/role/route.ts @@ -0,0 +1,60 @@ +import { eq, and } from "drizzle-orm"; +import { db } from "~/db"; +import { userRolesTable } from "~/db/schema"; +import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server"; +import { ApiError, createApiResponse } from "~/lib/api-utils"; + +export async function PUT( + request: Request, + context: { params: { id: string; userId: string } } +) { + try { + const { id, userId } = await context.params; + const studyId = parseInt(id); + + if (isNaN(studyId)) { + return ApiError.BadRequest("Invalid study ID"); + } + + const permissionCheck = await checkPermissions({ + studyId, + permission: PERMISSIONS.MANAGE_ROLES + }); + + if (permissionCheck.error) { + return permissionCheck.error; + } + + const { roleId } = await request.json(); + + if (!roleId || typeof roleId !== "number") { + return ApiError.BadRequest("Role ID is required"); + } + + // Update user's role in the study + await db.transaction(async (tx) => { + // Delete existing roles + await tx + .delete(userRolesTable) + .where( + and( + eq(userRolesTable.userId, userId), + eq(userRolesTable.studyId, studyId) + ) + ); + + // Assign new role + await tx + .insert(userRolesTable) + .values({ + userId, + roleId, + studyId, + }); + }); + + return createApiResponse({ message: "Role updated successfully" }); + } catch (error) { + return ApiError.ServerError(error); + } +} \ No newline at end of file diff --git a/src/app/api/studies/[id]/users/route.ts b/src/app/api/studies/[id]/users/route.ts new file mode 100644 index 0000000..d8b9992 --- /dev/null +++ b/src/app/api/studies/[id]/users/route.ts @@ -0,0 +1,73 @@ +import { eq, and } from "drizzle-orm"; +import { db } from "~/db"; +import { userRolesTable, usersTable, rolesTable } from "~/db/schema"; +import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server"; +import { ApiError, createApiResponse } from "~/lib/api-utils"; + +export async function GET( + request: Request, + context: { params: { id: string } } +) { + try { + const { id } = await context.params; + const studyId = parseInt(id); + + if (isNaN(studyId)) { + return ApiError.BadRequest("Invalid study ID"); + } + + const permissionCheck = await checkPermissions({ + studyId, + permission: PERMISSIONS.VIEW_STUDY + }); + + if (permissionCheck.error) { + return permissionCheck.error; + } + + // Get all users in the study with their roles + const studyUsers = await db + .select({ + id: usersTable.id, + email: usersTable.email, + name: usersTable.name, + roleId: rolesTable.id, + roleName: rolesTable.name, + }) + .from(userRolesTable) + .innerJoin(usersTable, eq(usersTable.id, userRolesTable.userId)) + .innerJoin(rolesTable, eq(rolesTable.id, userRolesTable.roleId)) + .where(eq(userRolesTable.studyId, studyId)); + + // Group roles by user + const users = studyUsers.reduce((acc, curr) => { + const existingUser = acc.find(u => u.id === curr.id); + if (!existingUser) { + acc.push({ + id: curr.id, + email: curr.email, + name: curr.name, + roles: [{ + id: curr.roleId, + name: curr.roleName, + }] + }); + } else if (curr.roleName && !existingUser.roles.some(r => r.id === curr.roleId)) { + existingUser.roles.push({ + id: curr.roleId, + name: curr.roleName, + }); + } + return acc; + }, [] as Array<{ + id: string; + email: string; + name: string | null; + roles: Array<{ id: number; name: string }>; + }>); + + return createApiResponse(users); + } catch (error) { + return ApiError.ServerError(error); + } +} \ No newline at end of file diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index a428e39..f6ba5ee 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,6 +1,7 @@ import { Sidebar } from "~/components/sidebar"; import { cn } from "~/lib/utils"; import { StudyProvider } from "~/context/StudyContext"; +import { ActiveStudyProvider } from "~/context/active-study"; export default function DashboardLayout({ children, @@ -8,17 +9,19 @@ export default function DashboardLayout({ children: React.ReactNode }) { return ( - -
- -
- {children} -
-
-
+ + +
+ +
+ {children} +
+
+
+
); } diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 03b9255..856712d 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -4,19 +4,18 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Button } from "~/components/ui/button"; -import { Users, BookOpen, Settings2 } from "lucide-react"; +import { BookOpen, Settings2 } from "lucide-react"; import { useToast } from "~/hooks/use-toast"; +import { Breadcrumb } from "~/components/breadcrumb"; interface DashboardStats { studyCount: number; - participantCount: number; activeInvitationCount: number; } export default function Dashboard() { const [stats, setStats] = useState({ studyCount: 0, - participantCount: 0, activeInvitationCount: 0, }); const [loading, setLoading] = useState(true); @@ -34,8 +33,7 @@ export default function Dashboard() { // For now, just show study count setStats({ - studyCount: studies.length, - participantCount: 0, + studyCount: studies.data.length, activeInvitationCount: 0, }); } catch (error) { @@ -59,35 +57,26 @@ export default function Dashboard() { } return ( -
+
+ +

Dashboard

Overview of your research studies

-
+
Total Studies -
{stats.studyCount ? stats.studyCount : 0}
+
{stats.studyCount}

Active research studies

- - - Total Participants - - - -
{stats.participantCount}
-

Across all studies

-
-
- Pending Invitations @@ -115,14 +104,6 @@ export default function Dashboard() { Manage Studies - @@ -139,7 +120,7 @@ export default function Dashboard() { • Invite collaborators using study settings

- • Add participants to begin collecting data + • Configure study parameters and forms

diff --git a/src/app/dashboard/participants/page.tsx b/src/app/dashboard/participants/page.tsx new file mode 100644 index 0000000..b3a60cc --- /dev/null +++ b/src/app/dashboard/participants/page.tsx @@ -0,0 +1,238 @@ +'use client'; + +import { PlusIcon, Trash2Icon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Button } from "~/components/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, + CardFooter +} from "~/components/ui/card"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "~/components/ui/select"; +import { usePermissions } from "~/hooks/usePermissions"; + +interface Study { + id: number; + title: string; +} + +interface Participant { + id: number; + name: string; + studyId: number; +} + +export default function Participants() { + const [studies, setStudies] = useState([]); + const [participants, setParticipants] = useState([]); + const [selectedStudyId, setSelectedStudyId] = useState(null); + const [participantName, setParticipantName] = useState(""); + const [loading, setLoading] = useState(true); + const { hasPermission } = usePermissions(); + + useEffect(() => { + fetchStudies(); + }, []); + + const fetchStudies = async () => { + try { + const response = await fetch('/api/studies'); + const data = await response.json(); + setStudies(data); + } catch (error) { + console.error('Error fetching studies:', error); + } finally { + setLoading(false); + } + }; + + const fetchParticipants = async (studyId: number) => { + try { + console.log(`Fetching participants for studyId: ${studyId}`); + const response = await fetch(`/api/participants?studyId=${studyId}`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + setParticipants(data); + } catch (error) { + console.error('Error fetching participants:', error); + } + }; + + const handleStudyChange = (studyId: string) => { + const id = parseInt(studyId); // Convert the string to a number + setSelectedStudyId(id); + fetchParticipants(id); + }; + + const addParticipant = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedStudyId) return; + + try { + const response = await fetch(`/api/participants`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: participantName, + studyId: selectedStudyId, + }), + }); + + if (response.ok) { + const newParticipant = await response.json(); + setParticipants([...participants, newParticipant]); + setParticipantName(""); + } else { + console.error('Error adding participant:', response.statusText); + } + } catch (error) { + console.error('Error adding participant:', error); + } + }; + + const deleteParticipant = async (id: number) => { + try { + const response = await fetch(`/api/participants/${id}`, { + method: 'DELETE', + }); + + if (response.ok) { + setParticipants(participants.filter(participant => participant.id !== id)); + } else { + console.error('Error deleting participant:', response.statusText); + } + } catch (error) { + console.error('Error deleting participant:', error); + } + }; + + if (loading) { + return
Loading...
; + } + + return ( +
+
+

Participants

+
+ + + + Study Selection + + Select a study to manage its participants + + + +
+ + +
+
+
+ + + + Add New Participant + + Add a new participant to the selected study + + + +
+
+ + setParticipantName(e.target.value)} + required + /> +
+ +
+
+
+ +
+ {participants.map((participant) => ( + + +
+
+ {participant.name} + + Participant ID: {participant.id} + +
+ {hasPermission('DELETE_PARTICIPANT') && ( + + )} +
+
+ + Study ID: {participant.studyId} + +
+ ))} + {participants.length === 0 && selectedStudyId && ( + + +

+ No participants found for this study. Add one above to get started. +

+
+
+ )} + {!selectedStudyId && ( + + +

+ Please select a study to view its participants. +

+
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/app/dashboard/studies/[id]/layout.tsx b/src/app/dashboard/studies/[id]/layout.tsx new file mode 100644 index 0000000..2ae4e34 --- /dev/null +++ b/src/app/dashboard/studies/[id]/layout.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { useParams } from "next/navigation"; +import { useEffect } from "react"; +import { useActiveStudy } from "~/context/active-study"; +import { Breadcrumb } from "~/components/breadcrumb"; +import { Skeleton } from "~/components/ui/skeleton"; + +export default function StudyLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { id } = useParams(); + const { studies, activeStudy, setActiveStudy, isLoading } = useActiveStudy(); + + useEffect(() => { + if (studies.length > 0 && id) { + const study = studies.find(s => s.id === parseInt(id as string)); + if (study && (!activeStudy || activeStudy.id !== study.id)) { + setActiveStudy(study); + } + } + }, [id, studies, activeStudy, setActiveStudy]); + + if (isLoading) { + return ( +
+
+ +
+ +
+ ); + } + + return ( +
+ + {children} +
+ ); +} \ No newline at end of file diff --git a/src/app/dashboard/studies/[id]/page.tsx b/src/app/dashboard/studies/[id]/page.tsx new file mode 100644 index 0000000..22ed525 --- /dev/null +++ b/src/app/dashboard/studies/[id]/page.tsx @@ -0,0 +1,172 @@ +'use client'; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; +import { + Users, + FileText, + BarChart, + PlayCircle, + Plus, + Settings2 +} from "lucide-react"; +import { useToast } from "~/hooks/use-toast"; +import Link from "next/link"; + +interface StudyStats { + participantCount: number; + completedTrialsCount: number; + pendingFormsCount: number; +} + +export default function StudyDashboard() { + const [stats, setStats] = useState({ + participantCount: 0, + completedTrialsCount: 0, + pendingFormsCount: 0, + }); + const [loading, setLoading] = useState(true); + const { id } = useParams(); + const { toast } = useToast(); + + useEffect(() => { + fetchStudyStats(); + }, [id]); + + const fetchStudyStats = async () => { + try { + const response = await fetch(`/api/studies/${id}/stats`); + if (!response.ok) throw new Error("Failed to fetch study statistics"); + const data = await response.json(); + setStats(data.data); + } catch (error) { + console.error("Error fetching study stats:", error); + toast({ + title: "Error", + description: "Failed to load study statistics", + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+ + + Participants + + + +
{stats.participantCount}
+

Total participants enrolled

+
+
+ + + + Completed Trials + + + +
{stats.completedTrialsCount}
+

Successfully completed trials

+
+
+ + + + Pending Forms + + + +
{stats.pendingFormsCount}
+

Forms awaiting completion

+
+
+
+ +
+ + + Quick Actions + Common tasks for this study + + + + + + + + + + + Recent Activity + Latest updates and changes + + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/dashboard/studies/[id]/participants/new/page.tsx b/src/app/dashboard/studies/[id]/participants/new/page.tsx new file mode 100644 index 0000000..c087a23 --- /dev/null +++ b/src/app/dashboard/studies/[id]/participants/new/page.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { useToast } from "~/hooks/use-toast"; +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; +import { useActiveStudy } from "~/context/active-study"; +import { hasPermission } from "~/lib/permissions-client"; +import { PERMISSIONS } from "~/lib/permissions"; + +export default function NewParticipant() { + const [name, setName] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const { id } = useParams(); + const router = useRouter(); + const { toast } = useToast(); + const { activeStudy } = useActiveStudy(); + + useEffect(() => { + if (!activeStudy || !hasPermission(activeStudy.permissions, PERMISSIONS.CREATE_PARTICIPANT)) { + router.push(`/dashboard/studies/${id}`); + } + }, [activeStudy, id, router]); + + if (!activeStudy || !hasPermission(activeStudy.permissions, PERMISSIONS.CREATE_PARTICIPANT)) { + return null; + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + + try { + const response = await fetch(`/api/studies/${id}/participants`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ name }), + }); + + if (!response.ok) { + throw new Error("Failed to create participant"); + } + + toast({ + title: "Success", + description: "Participant created successfully", + }); + + router.push(`/dashboard/studies/${id}/participants`); + } catch (error) { + console.error("Error creating participant:", error); + toast({ + title: "Error", + description: "Failed to create participant", + variant: "destructive", + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ +
+ + + + Add New Participant + + Create a new participant for {activeStudy?.title} + + + +
+
+ + setName(e.target.value)} + placeholder="Enter participant name" + required + /> +
+ +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/dashboard/studies/[id]/participants/page.tsx b/src/app/dashboard/studies/[id]/participants/page.tsx new file mode 100644 index 0000000..e76099f --- /dev/null +++ b/src/app/dashboard/studies/[id]/participants/page.tsx @@ -0,0 +1,215 @@ +'use client'; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; +import { useToast } from "~/hooks/use-toast"; +import { Plus, Trash2 } from "lucide-react"; +import Link from "next/link"; +import { useActiveStudy } from "~/context/active-study"; +import { hasPermission } from "~/lib/permissions-client"; +import { PERMISSIONS } from "~/lib/permissions"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "~/components/ui/alert-dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table"; + +interface Participant { + id: number; + name: string; + studyId: number; + createdAt: string; +} + +export default function ParticipantsList() { + const [participants, setParticipants] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const { id } = useParams(); + const { toast } = useToast(); + const { activeStudy } = useActiveStudy(); + + const canCreateParticipant = activeStudy && hasPermission(activeStudy.permissions, PERMISSIONS.CREATE_PARTICIPANT); + const canDeleteParticipant = activeStudy && hasPermission(activeStudy.permissions, PERMISSIONS.DELETE_PARTICIPANT); + const canViewNames = activeStudy && hasPermission(activeStudy.permissions, PERMISSIONS.VIEW_PARTICIPANT_NAMES); + + useEffect(() => { + fetchParticipants(); + }, [id]); + + const fetchParticipants = async () => { + try { + const response = await fetch(`/api/studies/${id}/participants`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) throw new Error("Failed to fetch participants"); + const data = await response.json(); + setParticipants(data.data || []); + } catch (error) { + console.error("Error fetching participants:", error); + toast({ + title: "Error", + description: "Failed to load participants", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + const handleDelete = async (participantId: number) => { + try { + const response = await fetch(`/api/studies/${id}/participants`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ participantId }), + }); + + if (!response.ok) throw new Error("Failed to delete participant"); + + setParticipants(participants.filter(p => p.id !== participantId)); + toast({ + title: "Success", + description: "Participant deleted successfully", + }); + } catch (error) { + console.error("Error deleting participant:", error); + toast({ + title: "Error", + description: "Failed to delete participant", + variant: "destructive", + }); + } + }; + + if (isLoading) { + return ( + + +

Loading participants...

+
+
+ ); + } + + return ( +
+
+
+

Participants

+

+ Manage study participants and their data +

+
+ {canCreateParticipant && ( + + )} +
+ + + + Study Participants + + All participants enrolled in {activeStudy?.title} + + + + {participants.length > 0 ? ( + + + + ID + Name + Added + {canDeleteParticipant && Actions} + + + + {participants.map((participant) => ( + + {participant.id} + + {canViewNames ? participant.name : `Participant ${participant.id}`} + + + {new Date(participant.createdAt).toLocaleDateString()} + + {canDeleteParticipant && ( + + + + + + + + Delete Participant + + Are you sure you want to delete this participant? This action cannot be undone. + + + + Cancel + handleDelete(participant.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + + + )} + + ))} + +
+ ) : ( +
+ No participants added yet + {canCreateParticipant && ( + <> + .{" "} + + Add your first participant + + + )} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/dashboard/studies/[id]/settings/page.tsx b/src/app/dashboard/studies/[id]/settings/page.tsx index c71421d..294e496 100644 --- a/src/app/dashboard/studies/[id]/settings/page.tsx +++ b/src/app/dashboard/studies/[id]/settings/page.tsx @@ -1,11 +1,15 @@ 'use client'; import { useState } from "react"; -import { useParams, useRouter, useSearchParams } from "next/navigation"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; +import { useParams, useRouter } from "next/navigation"; import { SettingsTab } from "~/components/studies/settings-tab"; import { ParticipantsTab } from "~/components/studies/participants-tab"; +import { UsersTab } from "~/components/studies/users-tab"; import { useEffect } from "react"; +import { PERMISSIONS } from "~/lib/permissions-client"; +import { Button } from "~/components/ui/button"; +import { Settings2Icon, UsersIcon, UserIcon } from "lucide-react"; +import { cn } from "~/lib/utils"; interface Study { id: number; @@ -18,17 +22,32 @@ export default function StudySettings() { const [study, setStudy] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState<'settings' | 'participants' | 'users'>('settings'); const { id } = useParams(); const router = useRouter(); - const searchParams = useSearchParams(); - const tab = searchParams.get('tab') || 'settings'; useEffect(() => { const fetchStudy = async () => { try { const response = await fetch(`/api/studies/${id}`); - if (!response.ok) throw new Error("Failed to fetch study"); + if (!response.ok) { + if (response.status === 403) { + router.push('/dashboard/studies'); + return; + } + throw new Error("Failed to fetch study"); + } const data = await response.json(); + + // Check if user has any required permissions + const requiredPermissions = [PERMISSIONS.EDIT_STUDY, PERMISSIONS.MANAGE_ROLES]; + const hasAccess = data.data.permissions.some(p => requiredPermissions.includes(p)); + + if (!hasAccess) { + router.push('/dashboard/studies'); + return; + } + setStudy(data.data); } catch (error) { console.error("Error fetching study:", error); @@ -39,43 +58,65 @@ export default function StudySettings() { }; fetchStudy(); - }, [id]); - - const handleTabChange = (value: string) => { - router.push(`/dashboard/studies/${id}/settings?tab=${value}`); - }; + }, [id, router]); if (isLoading) { - return
Loading...
; + return
Loading...
; } if (error || !study) { - return
{error || "Study not found"}
; + return
Error: {error}
; } return ( -
-
+
+

{study.title}

- Manage study settings and participants + Manage study settings, participants, and team members

- - - Settings - Participants - +
+
+ + + +
- - - - - - - - +
+
+ +
+
+ +
+
+ +
+
+
); } \ No newline at end of file diff --git a/src/app/dashboard/studies/new/page.tsx b/src/app/dashboard/studies/new/page.tsx new file mode 100644 index 0000000..c69c2d6 --- /dev/null +++ b/src/app/dashboard/studies/new/page.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { Textarea } from "~/components/ui/textarea"; +import { useToast } from "~/hooks/use-toast"; +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; +import { useActiveStudy } from "~/context/active-study"; +import { hasPermission } from "~/lib/permissions-client"; +import { PERMISSIONS } from "~/lib/permissions"; + +export default function NewStudy() { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const router = useRouter(); + const { toast } = useToast(); + const { refreshStudies } = useActiveStudy(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + + try { + const response = await fetch('/api/studies', { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ title, description }), + }); + + if (!response.ok) { + throw new Error("Failed to create study"); + } + + const data = await response.json(); + + toast({ + title: "Success", + description: "Study created successfully", + }); + + // Refresh studies list and redirect to the new study + await refreshStudies(); + router.push(`/dashboard/studies/${data.data.id}`); + } catch (error) { + console.error("Error creating study:", error); + toast({ + title: "Error", + description: "Failed to create study", + variant: "destructive", + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ +
+ + + + Create New Study + + Set up a new research study + + + +
+
+ + setTitle(e.target.value)} + placeholder="Enter study title" + required + /> +
+
+ +