fix: update user fields to match schema

- Replace firstName/lastName with name field in users API route
- Update user formatting in UsersTab component
- Add email fallback when name is not available
This commit is contained in:
2024-12-04 14:45:24 -05:00
parent 95b106d9e9
commit 29ce631901
36 changed files with 2700 additions and 167 deletions

View File

@@ -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",

53
pnpm-lock.yaml generated
View File

@@ -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)

View File

@@ -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)) {

View File

@@ -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)) {

View File

@@ -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<number>`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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 (
<StudyProvider>
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className={cn(
"flex-1 overflow-y-auto",
"lg:pt-8 p-8",
"pt-[calc(3.5rem+2rem)]"
)}>
{children}
</main>
</div>
</StudyProvider>
<ActiveStudyProvider>
<StudyProvider>
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className={cn(
"flex-1 overflow-y-auto",
"lg:pt-8 p-8",
"pt-[calc(3.5rem+2rem)]"
)}>
{children}
</main>
</div>
</StudyProvider>
</ActiveStudyProvider>
);
}

View File

@@ -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<DashboardStats>({
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 (
<div className="container py-6 space-y-6">
<div className="space-y-6">
<Breadcrumb />
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-muted-foreground">Overview of your research studies</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Studies</CardTitle>
<BookOpen className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.studyCount ? stats.studyCount : 0}</div>
<div className="text-2xl font-bold">{stats.studyCount}</div>
<p className="text-xs text-muted-foreground">Active research studies</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Participants</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.participantCount}</div>
<p className="text-xs text-muted-foreground">Across all studies</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Pending Invitations</CardTitle>
@@ -115,14 +104,6 @@ export default function Dashboard() {
<BookOpen className="mr-2 h-4 w-4" />
Manage Studies
</Button>
<Button
variant="outline"
className="w-full justify-start"
onClick={() => router.push('/dashboard/participants')}
>
<Users className="mr-2 h-4 w-4" />
Manage Participants
</Button>
</CardContent>
</Card>
@@ -139,7 +120,7 @@ export default function Dashboard() {
Invite collaborators using study settings
</p>
<p className="text-sm text-muted-foreground">
Add participants to begin collecting data
Configure study parameters and forms
</p>
</CardContent>
</Card>

View File

@@ -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<Study[]>([]);
const [participants, setParticipants] = useState<Participant[]>([]);
const [selectedStudyId, setSelectedStudyId] = useState<number | null>(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 <div>Loading...</div>;
}
return (
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Participants</h1>
</div>
<Card className="mb-8">
<CardHeader>
<CardTitle>Study Selection</CardTitle>
<CardDescription>
Select a study to manage its participants
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="study">Select Study</Label>
<Select onValueChange={handleStudyChange}>
<SelectTrigger>
<SelectValue placeholder="Select a study" />
</SelectTrigger>
<SelectContent>
{studies.map((study) => (
<SelectItem key={study.id} value={study.id.toString()}>
{study.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card className="mb-8">
<CardHeader>
<CardTitle>Add New Participant</CardTitle>
<CardDescription>
Add a new participant to the selected study
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={addParticipant} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Participant Name</Label>
<Input
type="text"
id="name"
value={participantName}
onChange={(e) => setParticipantName(e.target.value)}
required
/>
</div>
<Button type="submit" disabled={!selectedStudyId}>
<PlusIcon className="w-4 h-4 mr-2" />
Add Participant
</Button>
</form>
</CardContent>
</Card>
<div className="grid gap-4">
{participants.map((participant) => (
<Card key={participant.id}>
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle>{participant.name}</CardTitle>
<CardDescription className="mt-1.5">
Participant ID: {participant.id}
</CardDescription>
</div>
{hasPermission('DELETE_PARTICIPANT') && (
<Button
variant="ghost"
size="icon"
className="text-destructive"
onClick={() => deleteParticipant(participant.id)}
>
<Trash2Icon className="w-4 h-4" />
</Button>
)}
</div>
</CardHeader>
<CardFooter className="text-sm text-muted-foreground">
Study ID: {participant.studyId}
</CardFooter>
</Card>
))}
{participants.length === 0 && selectedStudyId && (
<Card>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">
No participants found for this study. Add one above to get started.
</p>
</CardContent>
</Card>
)}
{!selectedStudyId && (
<Card>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">
Please select a study to view its participants.
</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<div className="h-6">
<Skeleton className="h-4 w-[250px]" />
</div>
<Skeleton className="h-[400px]" />
</div>
);
}
return (
<div className="space-y-6">
<Breadcrumb />
{children}
</div>
);
}

View File

@@ -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<StudyStats>({
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 (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
);
}
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Participants</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.participantCount}</div>
<p className="text-xs text-muted-foreground">Total participants enrolled</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completed Trials</CardTitle>
<PlayCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.completedTrialsCount}</div>
<p className="text-xs text-muted-foreground">Successfully completed trials</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Pending Forms</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.pendingFormsCount}</div>
<p className="text-xs text-muted-foreground">Forms awaiting completion</p>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
<CardDescription>Common tasks for this study</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Button
variant="outline"
className="w-full justify-start"
asChild
>
<Link href={`/dashboard/studies/${id}/participants/new`}>
<Plus className="mr-2 h-4 w-4" />
Add Participant
</Link>
</Button>
<Button
variant="outline"
className="w-full justify-start"
asChild
>
<Link href={`/dashboard/studies/${id}/trials/new`}>
<PlayCircle className="mr-2 h-4 w-4" />
Start New Trial
</Link>
</Button>
<Button
variant="outline"
className="w-full justify-start"
asChild
>
<Link href={`/dashboard/studies/${id}/forms/new`}>
<FileText className="mr-2 h-4 w-4" />
Create Form
</Link>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>Latest updates and changes</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Button
variant="outline"
className="w-full justify-start"
asChild
>
<Link href={`/dashboard/studies/${id}/analysis`}>
<BarChart className="mr-2 h-4 w-4" />
View Analytics
</Link>
</Button>
<Button
variant="outline"
className="w-full justify-start"
asChild
>
<Link href={`/dashboard/studies/${id}/settings`}>
<Settings2 className="mr-2 h-4 w-4" />
Study Settings
</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Button
variant="ghost"
className="gap-2"
asChild
>
<Link href={`/dashboard/studies/${id}/participants`}>
<ArrowLeft className="h-4 w-4" />
Back to Participants
</Link>
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Add New Participant</CardTitle>
<CardDescription>
Create a new participant for {activeStudy?.title}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Participant Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter participant name"
required
/>
</div>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Participant"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<Participant[]>([]);
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 (
<Card>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">Loading participants...</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Participants</h2>
<p className="text-muted-foreground">
Manage study participants and their data
</p>
</div>
{canCreateParticipant && (
<Button asChild>
<Link href={`/dashboard/studies/${id}/participants/new`}>
<Plus className="mr-2 h-4 w-4" />
Add Participant
</Link>
</Button>
)}
</div>
<Card>
<CardHeader>
<CardTitle>Study Participants</CardTitle>
<CardDescription>
All participants enrolled in {activeStudy?.title}
</CardDescription>
</CardHeader>
<CardContent>
{participants.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Added</TableHead>
{canDeleteParticipant && <TableHead className="w-[100px]">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{participants.map((participant) => (
<TableRow key={participant.id}>
<TableCell>{participant.id}</TableCell>
<TableCell>
{canViewNames ? participant.name : `Participant ${participant.id}`}
</TableCell>
<TableCell>
{new Date(participant.createdAt).toLocaleDateString()}
</TableCell>
{canDeleteParticipant && (
<TableCell>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Participant</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this participant? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(participant.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="py-8 text-center text-muted-foreground">
No participants added yet
{canCreateParticipant && (
<>
.{" "}
<Link
href={`/dashboard/studies/${id}/participants/new`}
className="font-medium text-primary hover:underline"
>
Add your first participant
</Link>
</>
)}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<Study | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <div className="container py-6">Loading...</div>;
return <div>Loading...</div>;
}
if (error || !study) {
return <div className="container py-6 text-destructive">{error || "Study not found"}</div>;
return <div>Error: {error}</div>;
}
return (
<div className="container py-6 space-y-6">
<div className="flex flex-col gap-2">
<div className="container py-6">
<div className="flex flex-col gap-2 mb-6">
<h1 className="text-3xl font-bold tracking-tight">{study.title}</h1>
<p className="text-muted-foreground">
Manage study settings and participants
Manage study settings, participants, and team members
</p>
</div>
<Tabs value={tab} onValueChange={handleTabChange} className="space-y-6">
<TabsList>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="participants">Participants</TabsTrigger>
</TabsList>
<div className="flex gap-6">
<div className="w-48 flex flex-col gap-2">
<Button
variant={activeTab === 'settings' ? 'secondary' : 'ghost'}
className="justify-start"
onClick={() => setActiveTab('settings')}
>
<Settings2Icon className="mr-2 h-4 w-4" />
Settings
</Button>
<Button
variant={activeTab === 'participants' ? 'secondary' : 'ghost'}
className="justify-start"
onClick={() => setActiveTab('participants')}
>
<UserIcon className="mr-2 h-4 w-4" />
Participants
</Button>
<Button
variant={activeTab === 'users' ? 'secondary' : 'ghost'}
className="justify-start"
onClick={() => setActiveTab('users')}
>
<UsersIcon className="mr-2 h-4 w-4" />
Users
</Button>
</div>
<TabsContent value="settings" className="space-y-6">
<SettingsTab study={study} />
</TabsContent>
<TabsContent value="participants" className="space-y-6">
<ParticipantsTab studyId={study.id} permissions={study.permissions} />
</TabsContent>
</Tabs>
<div className="flex-1">
<div className={cn(activeTab === 'settings' ? 'block' : 'hidden')}>
<SettingsTab study={study} />
</div>
<div className={cn(activeTab === 'participants' ? 'block' : 'hidden')}>
<ParticipantsTab studyId={study.id} permissions={study.permissions} />
</div>
<div className={cn(activeTab === 'users' ? 'block' : 'hidden')}>
<UsersTab studyId={study.id} permissions={study.permissions} />
</div>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Button
variant="ghost"
className="gap-2"
asChild
>
<Link href="/dashboard/studies">
<ArrowLeft className="h-4 w-4" />
Back to Studies
</Link>
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Create New Study</CardTitle>
<CardDescription>
Set up a new research study
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Study Title</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter study title"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter study description"
rows={4}
/>
</div>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Study"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -250,7 +250,11 @@ export default function Studies() {
<Card>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">
No studies created yet. Create your first study above.
No studies created yet.{' '}
{hasPermission(studies[0]?.permissions || [], PERMISSIONS.CREATE_STUDY)
? "Create your first study above."
: "Ask your administrator to create your first study."
}
</p>
</CardContent>
</Card>

View File

@@ -10,8 +10,8 @@ body {
:root {
--background: 210 50% 98%;
--foreground: 215 25% 27%;
--card: 210 50% 98%; /* Card background color */
--card-foreground: 215 25% 27%; /* Card text color */
--card: 210 50% 98%;
--card-foreground: 215 25% 27%;
--popover: 210 50% 98%;
--popover-foreground: 215 25% 27%;
--primary: 215 60% 40%;
@@ -33,34 +33,36 @@ body {
--gradient-start: 210 50% 96%;
--gradient-end: 210 50% 98%;
/* Update sidebar variables */
--sidebar-background-top: 210 55% 92%;
--sidebar-background-bottom: 210 55% 88%;
/* Updated sidebar variables for a clean, light look */
--sidebar-background: 210 50% 98%;
--sidebar-foreground: 215 25% 27%;
--sidebar-muted: 215 20% 50%;
--sidebar-hover: 210 60% 86%;
--sidebar-hover: 210 50% 94%;
--sidebar-border: 214 32% 91%;
--sidebar-separator: 214 32% 91%;
--sidebar-active: 210 50% 92%;
--card-level-1: 210 50% 95%; /* Level 1 card background color */
--card-level-2: 210 50% 90%; /* Level 2 card background color */
--card-level-3: 210 50% 85%; /* Level 3 card background color */
--card-level-1: 210 50% 95%;
--card-level-2: 210 50% 90%;
--card-level-3: 210 50% 85%;
}
.dark {
--background: 220 20% 15%; /* Dark mode background */
--foreground: 220 20% 90%; /* Dark mode foreground */
--card: 220 20% 15%; /* Dark mode card background color */
--card-foreground: 220 20% 90%; /* Dark mode card text color */
--background: 220 20% 15%;
--foreground: 220 20% 90%;
--card: 220 20% 15%;
--card-foreground: 220 20% 90%;
--popover: 220 20% 15%;
--popover-foreground: 220 20% 90%;
--primary: 220 60% 50%;
--primary-foreground: 220 20% 90%;
--secondary: 220 30% 20%; /* Darker secondary */
--secondary: 220 30% 20%;
--secondary-foreground: 220 20% 90%;
--muted: 220 30% 20%;
--muted-foreground: 220 20% 70%;
--accent: 220 30% 20%;
--accent-foreground: 220 20% 90%;
--destructive: 0 62% 40%; /* Darker destructive */
--destructive: 0 62% 40%;
--destructive-foreground: 220 20% 90%;
--border: 220 30% 20%;
--input: 220 30% 20%;
@@ -70,16 +72,18 @@ body {
--gradient-start: 220 20% 12%;
--gradient-end: 220 20% 15%;
/* Update sidebar variables for dark mode */
/* Updated sidebar variables for dark mode */
--sidebar-background-top: 220 20% 15%;
--sidebar-background-bottom: 220 20% 12%;
--sidebar-background-bottom: 220 20% 15%;
--sidebar-foreground: 220 20% 90%;
--sidebar-muted: 220 20% 70%;
--sidebar-hover: 220 30% 20%;
--sidebar-muted: 220 20% 60%;
--sidebar-hover: 220 20% 20%;
--sidebar-border: 220 20% 25%;
--sidebar-separator: 220 20% 22%;
--card-level-1: 220 20% 12%; /* Dark mode Level 1 card background color */
--card-level-2: 220 20% 10%; /* Dark mode Level 2 card background color */
--card-level-3: 220 20% 8%; /* Dark mode Level 3 card background color */
--card-level-1: 220 20% 12%;
--card-level-2: 220 20% 10%;
--card-level-3: 220 20% 8%;
}
/* Add these utility classes */
@@ -104,3 +108,20 @@ body {
@apply bg-background text-foreground;
}
}
/* Sidebar specific styles */
.sidebar-separator {
@apply my-3 border-t border-[hsl(var(--sidebar-separator))] opacity-60;
}
.sidebar-dropdown-content {
@apply bg-[hsl(var(--sidebar-background))] border-[hsl(var(--sidebar-border))];
}
.sidebar-button {
@apply hover:bg-[hsl(var(--sidebar-hover))] text-[hsl(var(--sidebar-foreground))];
}
.sidebar-button[data-active="true"] {
@apply bg-[hsl(var(--sidebar-active))] font-medium;
}

View File

@@ -0,0 +1,93 @@
'use client';
import { ChevronRight } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useActiveStudy } from "~/context/active-study";
interface BreadcrumbItem {
label: string;
href?: string;
}
export function Breadcrumb() {
const pathname = usePathname();
const { activeStudy } = useActiveStudy();
const getBreadcrumbs = (): BreadcrumbItem[] => {
const items: BreadcrumbItem[] = [{ label: 'Dashboard', href: '/dashboard' }];
const path = pathname.split('/').filter(Boolean);
// Handle studies list page
if (path[1] === 'studies' && !activeStudy) {
items.push({ label: 'Studies', href: '/dashboard/studies' });
if (path[2] === 'new') {
items.push({ label: 'New Study' });
}
return items;
}
// Handle active study pages
if (activeStudy) {
items.push({
label: 'Studies',
href: '/dashboard/studies'
});
items.push({
label: activeStudy.title,
href: `/dashboard/studies/${activeStudy.id}`
});
// Add section based on URL
if (path.length > 3) {
const section = path[3];
const sectionLabel = section.charAt(0).toUpperCase() + section.slice(1);
if (section === 'new') {
items.push({
label: `New ${path[2].slice(0, -1)}`,
href: `/dashboard/studies/${activeStudy.id}/${path[2]}/new`
});
} else {
items.push({
label: sectionLabel,
href: `/dashboard/studies/${activeStudy.id}/${section}`
});
}
}
}
return items;
};
const breadcrumbs = getBreadcrumbs();
if (breadcrumbs.length <= 1) return null;
return (
<div className="flex items-center space-x-2 text-sm text-muted-foreground mb-6">
{breadcrumbs.map((item, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<div key={item.label} className="flex items-center">
{index > 0 && <ChevronRight className="h-4 w-4 mx-2" />}
{item.href && !isLast ? (
<Link
href={item.href}
className="hover:text-foreground transition-colors"
>
{item.label}
</Link>
) : (
<span className={isLast ? "text-foreground font-medium" : ""}>
{item.label}
</span>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -5,50 +5,172 @@ import {
BarChartIcon,
UsersRoundIcon,
LandPlotIcon,
BotIcon,
FolderIcon,
FileTextIcon,
LayoutDashboard,
Menu,
Settings
Settings,
ChevronDown,
FolderIcon,
PlusIcon
} from "lucide-react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { usePathname, useRouter } from "next/navigation"
import { useState } from "react"
import { Button } from "~/components/ui/button"
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "~/components/ui/sheet"
import { cn } from "~/lib/utils"
import { Logo } from "~/components/logo"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select"
import { Separator } from "~/components/ui/separator"
import { useActiveStudy } from "~/context/active-study"
const navItems = [
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ name: "Studies", href: "/dashboard/studies", icon: FolderIcon },
{ name: "Trials", href: "/dashboard/trials", icon: LandPlotIcon },
{ name: "Forms", href: "/dashboard/forms", icon: FileTextIcon },
{ name: "Data Analysis", href: "/dashboard/analysis", icon: BarChartIcon },
{ name: "Settings", href: "/dashboard/settings", icon: Settings },
const getNavItems = (studyId?: number) => [
{
name: "Dashboard",
href: studyId ? `/dashboard/studies/${studyId}` : "/dashboard",
icon: LayoutDashboard,
exact: true,
requiresStudy: false
},
{
name: "Participants",
href: `/dashboard/studies/${studyId}/participants`,
icon: UsersRoundIcon,
requiresStudy: true,
baseRoute: "participants"
},
{
name: "Trials",
href: `/dashboard/studies/${studyId}/trials`,
icon: LandPlotIcon,
requiresStudy: true,
baseRoute: "trials"
},
{
name: "Forms",
href: `/dashboard/studies/${studyId}/forms`,
icon: FileTextIcon,
requiresStudy: true,
baseRoute: "forms"
},
{
name: "Data Analysis",
href: `/dashboard/studies/${studyId}/analysis`,
icon: BarChartIcon,
requiresStudy: true,
baseRoute: "analysis"
},
{
name: "Settings",
href: `/dashboard/studies/${studyId}/settings`,
icon: Settings,
requiresStudy: true,
baseRoute: "settings"
},
];
export function Sidebar() {
const pathname = usePathname()
const router = useRouter()
const [isOpen, setIsOpen] = useState(false)
const { user } = useUser()
const { activeStudy, setActiveStudy, studies, isLoading } = useActiveStudy()
const navItems = getNavItems(activeStudy?.id)
const visibleNavItems = activeStudy
? navItems
: navItems.filter(item => !item.requiresStudy)
const isActiveRoute = (item: { href: string, exact?: boolean, baseRoute?: string }) => {
if (item.exact) {
return pathname === item.href;
}
if (item.baseRoute && activeStudy) {
const pattern = new RegExp(`/dashboard/studies/\\d+/${item.baseRoute}`);
return pattern.test(pathname);
}
return pathname.startsWith(item.href);
};
const handleStudyChange = (value: string) => {
if (value === "all") {
setActiveStudy(null);
router.push("/dashboard/studies");
} else {
const study = studies.find(s => s.id.toString() === value);
if (study) {
setActiveStudy(study);
router.push(`/dashboard/studies/${study.id}`);
}
}
};
const SidebarContent = () => (
<div className="flex h-full flex-col bg-gradient-to-b from-[hsl(var(--sidebar-background-top))] to-[hsl(var(--sidebar-background-bottom))]">
<div className="flex h-full flex-col">
<div className="p-4">
<Select
value={activeStudy?.id?.toString() || "all"}
onValueChange={handleStudyChange}
>
<SelectTrigger className="w-full sidebar-button">
<div className="flex items-center justify-between">
<span className="truncate">
{activeStudy?.title || "All Studies"}
</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</div>
</SelectTrigger>
<SelectContent className="sidebar-dropdown-content">
<SelectItem value="all" className="sidebar-button">
<div className="flex items-center">
<FolderIcon className="h-4 w-4 mr-2" />
All Studies
</div>
</SelectItem>
<Separator className="sidebar-separator" />
{studies.map((study) => (
<SelectItem
key={study.id}
value={study.id.toString()}
className="sidebar-button"
>
{study.title}
</SelectItem>
))}
<Separator className="sidebar-separator" />
<Button
variant="ghost"
className="w-full justify-start sidebar-button"
asChild
>
<Link href="/dashboard/studies/new">
<PlusIcon className="h-4 w-4 mr-2" />
Create New Study
</Link>
</Button>
</SelectContent>
</Select>
</div>
<nav className="flex-1 overflow-y-auto p-4">
<ul className="space-y-2">
{navItems.map((item) => {
{visibleNavItems.map((item) => {
const IconComponent = item.icon;
const isActive = isActiveRoute(item);
return (
<li key={item.href}>
<Button
asChild
variant="ghost"
className={cn(
"w-full justify-start text-[hsl(var(--sidebar-foreground))] hover:bg-[hsl(var(--sidebar-hover))]",
pathname === item.href && "bg-[hsl(var(--sidebar-hover))] font-semibold"
)}
className="w-full justify-start sidebar-button"
data-active={isActive}
>
<Link href={item.href} onClick={() => setIsOpen(false)}>
<IconComponent className="h-5 w-5 mr-3" />
@@ -60,13 +182,20 @@ export function Sidebar() {
})}
</ul>
</nav>
<div className="border-t p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<UserButton />
<div>
<p className="text-sm font-medium text-[hsl(var(--sidebar-foreground))]">{user?.fullName ?? 'User'}</p>
<p className="text-xs text-[hsl(var(--sidebar-muted))]">{user?.primaryEmailAddress?.emailAddress ?? 'user@example.com'}</p>
<div className="p-4">
<div className="border-t border-[hsl(var(--sidebar-separator))]">
<div className="flex items-center justify-between pt-4">
<div className="flex items-center space-x-4">
<UserButton />
<div>
<p className="text-sm font-medium text-[hsl(var(--sidebar-foreground))]">
{user?.fullName ?? user?.username ?? 'User'}
</p>
<p className="text-xs text-[hsl(var(--sidebar-muted))]">
{user?.primaryEmailAddress?.emailAddress ?? 'user@example.com'}
</p>
</div>
</div>
</div>
</div>
@@ -77,7 +206,7 @@ export function Sidebar() {
return (
<>
<div className="lg:hidden fixed top-0 left-0 right-0 z-50">
<div className="flex h-14 items-center justify-between border-b px-4 bg-background">
<div className="flex h-14 items-center justify-between border-b border-[hsl(var(--sidebar-border))]">
<Logo
href="/dashboard"
className="text-[hsl(var(--sidebar-foreground))]"
@@ -85,19 +214,22 @@ export function Sidebar() {
/>
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetTrigger asChild>
<Button variant="ghost" className="h-14 w-14 px-0">
<Button variant="ghost" className="h-14 w-14 px-0 sidebar-button">
<Menu className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent side="top" className="w-full">
<SheetContent
side="left"
className="w-full p-0 border-[hsl(var(--sidebar-border))]"
>
<SheetTitle className="sr-only">Navigation Menu</SheetTitle>
<SidebarContent />
</SheetContent>
</Sheet>
</div>
</div>
<div className="hidden lg:flex lg:w-64 lg:flex-col lg:border-r lg:bg-gradient-to-b lg:from-[hsl(var(--sidebar-background-top))] lg:to-[hsl(var(--sidebar-background-bottom))]">
<div className="flex h-14 items-center border-b px-4">
<div className="hidden lg:flex lg:w-64 lg:flex-col lg:border-r lg:border-[hsl(var(--sidebar-border))]">
<div className="flex h-14 items-center border-b border-[hsl(var(--sidebar-border))] px-4">
<Logo
href="/dashboard"
className="text-[hsl(var(--sidebar-foreground))]"

View File

@@ -0,0 +1,136 @@
'use client';
import { useState, useEffect } from "react";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { useToast } from "~/hooks/use-toast";
import { PERMISSIONS } from "~/lib/permissions-client";
import { InviteUserDialog } from "./invite-user-dialog";
interface Invitation {
id: string;
email: string;
roleName: string;
accepted: boolean;
expiresAt: string;
}
interface InvitationsTabProps {
studyId: number;
permissions: string[];
}
export function InvitationsTab({ studyId, permissions }: InvitationsTabProps) {
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { toast } = useToast();
const hasPermission = (permission: string) => permissions.includes(permission);
const canManageRoles = hasPermission(PERMISSIONS.MANAGE_ROLES);
useEffect(() => {
fetchInvitations();
}, [studyId]);
const fetchInvitations = async () => {
try {
const response = await fetch(`/api/invitations?studyId=${studyId}`);
if (!response.ok) throw new Error("Failed to fetch invitations");
const data = await response.json();
setInvitations(data.data || []);
} catch (error) {
console.error("Error fetching invitations:", error);
toast({
title: "Error",
description: "Failed to load invitations",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const handleDeleteInvitation = async (invitationId: string) => {
try {
const response = await fetch(`/api/invitations/${invitationId}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Failed to delete invitation");
}
setInvitations(invitations.filter(inv => inv.id !== invitationId));
toast({
title: "Success",
description: "Invitation deleted successfully",
});
} catch (error) {
console.error("Error deleting invitation:", error);
toast({
title: "Error",
description: "Failed to delete invitation",
variant: "destructive",
});
}
};
if (isLoading) {
return (
<Card>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">Loading invitations...</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Manage Invitations</CardTitle>
<CardDescription>
Invite researchers and participants to collaborate on this study
</CardDescription>
</div>
<InviteUserDialog studyId={studyId} onInviteSent={fetchInvitations} />
</div>
</CardHeader>
<CardContent className="space-y-6">
{invitations.length > 0 ? (
<div className="space-y-4">
{invitations.map((invitation) => (
<div
key={invitation.id}
className="flex items-center justify-between p-4 border rounded-lg bg-card"
>
<div>
<p className="font-medium">{invitation.email}</p>
<p className="text-sm text-muted-foreground">
Role: {invitation.roleName}
{invitation.accepted ? " • Accepted" : " • Pending"}
</p>
</div>
{!invitation.accepted && (
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteInvitation(invitation.id)}
>
Cancel
</Button>
)}
</div>
))}
</div>
) : (
<p className="text-muted-foreground">No invitations sent yet.</p>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,167 @@
'use client';
import { useState, useEffect } from "react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { useToast } from "~/hooks/use-toast";
interface Role {
id: number;
name: string;
description: string;
}
interface InviteUserDialogProps {
studyId: number;
onInviteSent: () => void;
}
export function InviteUserDialog({ studyId, onInviteSent }: InviteUserDialogProps) {
const [email, setEmail] = useState("");
const [roleId, setRoleId] = useState<string>("");
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [roles, setRoles] = useState<Role[]>([]);
const { toast } = useToast();
// Fetch available roles when dialog opens
useEffect(() => {
if (isOpen) {
fetchRoles();
}
}, [isOpen]);
const fetchRoles = async () => {
try {
const response = await fetch("/api/roles");
if (!response.ok) {
throw new Error("Failed to fetch roles");
}
const data = await response.json();
// Filter out admin and PI roles
setRoles(data.filter((role: Role) =>
!['admin', 'principal_investigator'].includes(role.name)
));
} catch (error) {
console.error("Error fetching roles:", error);
toast({
title: "Error",
description: "Failed to load roles",
variant: "destructive",
});
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !roleId) return;
setIsLoading(true);
try {
const response = await fetch("/api/invitations", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email,
roleId: parseInt(roleId, 10),
studyId,
}),
});
if (!response.ok) {
throw new Error("Failed to send invitation");
}
toast({
title: "Success",
description: "Invitation sent successfully",
});
setIsOpen(false);
setEmail("");
setRoleId("");
onInviteSent();
} catch (error) {
console.error("Error sending invitation:", error);
toast({
title: "Error",
description: "Failed to send invitation",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>Invite User</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite User</DialogTitle>
<DialogDescription>
Send an invitation to collaborate on this study
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="Enter email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={roleId} onValueChange={setRoleId} required>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id.toString()}>
{role.name.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? "Sending..." : "Send Invitation"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -38,6 +38,11 @@ export function ParticipantsTab({ studyId, permissions }: ParticipantsTabProps)
const [error, setError] = useState<string | null>(null);
const { toast } = useToast();
const hasPermission = (permission: string) => permissions.includes(permission);
const canCreateParticipant = hasPermission(PERMISSIONS.CREATE_PARTICIPANT);
const canDeleteParticipant = hasPermission(PERMISSIONS.DELETE_PARTICIPANT);
const canViewNames = hasPermission(PERMISSIONS.VIEW_PARTICIPANT_NAMES);
useEffect(() => {
fetchParticipants();
}, [studyId]);
@@ -121,8 +126,6 @@ export function ParticipantsTab({ studyId, permissions }: ParticipantsTabProps)
}
};
const hasPermission = (permission: string) => permissions.includes(permission);
if (isLoading) {
return (
<Card>
@@ -133,19 +136,9 @@ export function ParticipantsTab({ studyId, permissions }: ParticipantsTabProps)
);
}
if (error) {
return (
<Card>
<CardContent className="py-8">
<p className="text-center text-destructive">{error}</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{hasPermission(PERMISSIONS.CREATE_PARTICIPANT) && (
{canCreateParticipant && (
<Card>
<CardHeader>
<CardTitle>Add New Participant</CardTitle>
@@ -181,12 +174,13 @@ export function ParticipantsTab({ studyId, permissions }: ParticipantsTabProps)
<div>
<h3 className="font-semibold">
{participant.name}
{!canViewNames && <span className="text-sm text-muted-foreground ml-2">(ID: {participant.id})</span>}
</h3>
<p className="text-sm text-muted-foreground">
Added {new Date(participant.createdAt).toLocaleDateString()}
</p>
</div>
{hasPermission(PERMISSIONS.DELETE_PARTICIPANT) && (
{canDeleteParticipant && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm">
@@ -222,7 +216,7 @@ export function ParticipantsTab({ studyId, permissions }: ParticipantsTabProps)
<Card>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">
No participants added yet. Add your first participant above.
No participants added yet{canCreateParticipant ? ". Add your first participant above" : ""}.
</p>
</CardContent>
</Card>

View File

@@ -8,6 +8,7 @@ import { Button } from "~/components/ui/button";
import { useToast } from "~/hooks/use-toast";
import { useState } from "react";
import { PERMISSIONS } from "~/lib/permissions-client";
import { useRouter } from "next/navigation";
interface SettingsTabProps {
study: {
@@ -21,13 +22,18 @@ interface SettingsTabProps {
export function SettingsTab({ study }: SettingsTabProps) {
const [title, setTitle] = useState(study.title);
const [description, setDescription] = useState(study.description || "");
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const router = useRouter();
const hasPermission = (permission: string) => study.permissions.includes(permission);
const canEditStudy = hasPermission(PERMISSIONS.EDIT_STUDY);
const updateStudy = async (e: React.FormEvent) => {
e.preventDefault();
if (!canEditStudy) return;
setIsLoading(true);
try {
const response = await fetch(`/api/studies/${study.id}`, {
method: "PATCH",
@@ -37,7 +43,13 @@ export function SettingsTab({ study }: SettingsTabProps) {
body: JSON.stringify({ title, description }),
});
if (!response.ok) throw new Error("Failed to update study");
if (!response.ok) {
if (response.status === 403) {
router.push('/dashboard/studies');
return;
}
throw new Error("Failed to update study");
}
toast({
title: "Success",
@@ -47,12 +59,26 @@ export function SettingsTab({ study }: SettingsTabProps) {
console.error("Error updating study:", error);
toast({
title: "Error",
description: "Failed to update study",
description: error instanceof Error ? error.message : "Failed to update study",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
if (!canEditStudy) {
return (
<Card>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">
You don't have permission to edit this study.
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
@@ -69,7 +95,7 @@ export function SettingsTab({ study }: SettingsTabProps) {
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter study title"
required
disabled={!canEditStudy}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
@@ -79,14 +105,12 @@ export function SettingsTab({ study }: SettingsTabProps) {
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter study description"
disabled={!canEditStudy}
disabled={isLoading}
/>
</div>
{canEditStudy && (
<Button type="submit">
Save Changes
</Button>
)}
<Button type="submit" disabled={isLoading}>
{isLoading ? "Saving..." : "Save Changes"}
</Button>
</form>
</CardContent>
</Card>

View File

@@ -0,0 +1,336 @@
'use client';
import { useState, useEffect } from "react";
import { UserAvatar } from "~/components/user-avatar";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { useToast } from "~/hooks/use-toast";
import { PERMISSIONS } from "~/lib/permissions-client";
import { InviteUserDialog } from "./invite-user-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Trash2Icon } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
interface User {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
roles: Array<{ id: number; name: string }>;
}
interface Invitation {
id: string;
email: string;
roleName: string;
accepted: boolean;
expiresAt: string;
}
interface Role {
id: number;
name: string;
description: string;
}
interface UsersTabProps {
studyId: number;
permissions: string[];
}
export function UsersTab({ studyId, permissions }: UsersTabProps) {
const [users, setUsers] = useState<User[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { toast } = useToast();
const hasPermission = (permission: string) => permissions.includes(permission);
const canManageRoles = hasPermission(PERMISSIONS.MANAGE_ROLES);
useEffect(() => {
fetchData();
}, [studyId]);
const fetchData = async () => {
try {
await Promise.all([
fetchUsers(),
fetchInvitations(),
fetchRoles(),
]);
} finally {
setIsLoading(false);
}
};
const fetchUsers = async () => {
try {
const response = await fetch(`/api/studies/${studyId}/users`);
if (!response.ok) throw new Error("Failed to fetch users");
const data = await response.json();
setUsers(data.data || []);
} catch (error) {
console.error("Error fetching users:", error);
toast({
title: "Error",
description: "Failed to load users",
variant: "destructive",
});
}
};
const fetchInvitations = async () => {
try {
const response = await fetch(`/api/invitations?studyId=${studyId}`);
if (!response.ok) throw new Error("Failed to fetch invitations");
const data = await response.json();
setInvitations(data.data || []);
} catch (error) {
console.error("Error fetching invitations:", error);
toast({
title: "Error",
description: "Failed to load invitations",
variant: "destructive",
});
}
};
const fetchRoles = async () => {
try {
const response = await fetch("/api/roles");
if (!response.ok) throw new Error("Failed to fetch roles");
const data = await response.json();
setRoles(data.filter((role: Role) =>
!['admin'].includes(role.name)
));
} catch (error) {
console.error("Error fetching roles:", error);
toast({
title: "Error",
description: "Failed to load roles",
variant: "destructive",
});
}
};
const handleRoleChange = async (userId: string, newRoleId: string) => {
try {
const response = await fetch(`/api/studies/${studyId}/users/${userId}/role`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
roleId: parseInt(newRoleId, 10),
}),
});
if (!response.ok) throw new Error("Failed to update role");
toast({
title: "Success",
description: "User role updated successfully",
});
// Refresh users list
fetchUsers();
} catch (error) {
console.error("Error updating role:", error);
toast({
title: "Error",
description: "Failed to update user role",
variant: "destructive",
});
}
};
const handleDeleteInvitation = async (invitationId: string) => {
try {
const response = await fetch(`/api/invitations/${invitationId}`, {
method: "DELETE",
});
if (!response.ok) throw new Error("Failed to delete invitation");
setInvitations(invitations.filter(inv => inv.id !== invitationId));
toast({
title: "Success",
description: "Invitation deleted successfully",
});
} catch (error) {
console.error("Error deleting invitation:", error);
toast({
title: "Error",
description: "Failed to delete invitation",
variant: "destructive",
});
}
};
const formatName = (user: User) => {
return user.name || user.email;
};
const formatRoleName = (name: string) => {
return name
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
};
if (isLoading) {
return <div>Loading...</div>;
}
const pendingInvitations = invitations.filter(inv => !inv.accepted);
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold tracking-tight">Team Members</h2>
{canManageRoles && <InviteUserDialog studyId={studyId} onInviteSent={fetchInvitations} />}
</div>
<Card>
<CardHeader>
<CardTitle>Study Members</CardTitle>
<CardDescription>
Manage users and their roles in this study
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="flex items-center gap-2">
<UserAvatar
user={{
name: formatName(user),
email: user.email,
}}
/>
<span>{formatName(user)}</span>
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
{canManageRoles ? (
<Select
value={user.roles[0]?.id.toString()}
onValueChange={(value) => handleRoleChange(user.id, value)}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id.toString()}>
{formatRoleName(role.name)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span>{formatRoleName(user.roles[0]?.name || '')}</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{pendingInvitations.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Pending Invitations</CardTitle>
<CardDescription>
Outstanding invitations to join the study
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Expires</TableHead>
{canManageRoles && <TableHead className="w-[100px]">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{pendingInvitations.map((invitation) => (
<TableRow key={invitation.id}>
<TableCell>{invitation.email}</TableCell>
<TableCell>{formatRoleName(invitation.roleName)}</TableCell>
<TableCell>{new Date(invitation.expiresAt).toLocaleDateString()}</TableCell>
{canManageRoles && (
<TableCell>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon">
<Trash2Icon className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Invitation</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this invitation? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteInvitation(invitation.id)}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "~/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,114 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cn } from "~/lib/utils"
import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "~/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,15 @@
import { cn } from "~/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

120
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "~/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,27 @@
import { Avatar, AvatarFallback } from "~/components/ui/avatar";
interface UserAvatarProps {
user: {
name?: string | null;
email: string;
};
className?: string;
}
export function UserAvatar({ user, className }: UserAvatarProps) {
function getInitials(name: string) {
return name
.split(' ')
.map(part => part[0])
.join('')
.toUpperCase();
}
const initials = user.name ? getInitials(user.name) : user.email[0].toUpperCase();
return (
<Avatar className={className}>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
interface Study {
id: number;
title: string;
description: string | null;
userId: string;
environment: string;
createdAt: Date;
updatedAt: Date | null;
permissions: string[];
}
interface ActiveStudyContextType {
activeStudy: Study | null;
setActiveStudy: (study: Study | null) => void;
studies: Study[];
isLoading: boolean;
error: string | null;
refreshStudies: () => Promise<void>;
}
const ActiveStudyContext = createContext<ActiveStudyContextType | undefined>(undefined);
export function ActiveStudyProvider({ children }: { children: React.ReactNode }) {
const [activeStudy, setActiveStudy] = useState<Study | null>(null);
const [studies, setStudies] = useState<Study[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const pathname = usePathname();
const fetchStudies = async () => {
try {
const response = await fetch('/api/studies', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) throw new Error('Failed to fetch studies');
const data = await response.json();
const studiesWithDates = (data.data || []).map((study: any) => ({
...study,
createdAt: new Date(study.createdAt),
updatedAt: study.updatedAt ? new Date(study.updatedAt) : null,
}));
setStudies(studiesWithDates);
if (studiesWithDates.length === 1 && !activeStudy) {
setActiveStudy(studiesWithDates[0]);
}
} catch (error) {
console.error('Error fetching studies:', error);
setError(error instanceof Error ? error.message : 'Failed to load studies');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchStudies();
}, []);
useEffect(() => {
const studyIdMatch = pathname.match(/\/dashboard\/studies\/(\d+)/);
if (studyIdMatch) {
const studyId = parseInt(studyIdMatch[1]);
const study = studies.find(s => s.id === studyId);
if (study && (!activeStudy || activeStudy.id !== study.id)) {
setActiveStudy(study);
}
} else if (!pathname.includes('/studies/new')) {
setActiveStudy(null);
}
}, [pathname, studies]);
const value = {
activeStudy,
setActiveStudy,
studies,
isLoading,
error,
refreshStudies: fetchStudies,
};
return (
<ActiveStudyContext.Provider value={value}>
{children}
</ActiveStudyContext.Provider>
);
}
export function useActiveStudy() {
const context = useContext(ActiveStudyContext);
if (context === undefined) {
throw new Error('useActiveStudy must be used within an ActiveStudyProvider');
}
return context;
}

View File

@@ -7,16 +7,9 @@ config({ path: '.env.local' });
async function dropAllTables() {
try {
// drop all tables, regardless of name
await sql`
DROP TABLE IF EXISTS
user_roles,
role_permissions,
permissions,
roles,
participant,
study,
users
CASCADE;
DROP TABLE IF EXISTS ${sql.raw(Object.values(tables).map(table => table.name).join(', '))}
`;
console.log('All tables dropped successfully');
} catch (error) {

View File

@@ -34,7 +34,6 @@ export const ROLE_PERMISSIONS: Record<RoleCode, Array<keyof typeof PERMISSIONS>>
RESEARCHER: [
'VIEW_STUDY',
'VIEW_PARTICIPANT_NAMES',
'CREATE_PARTICIPANT',
'EDIT_PARTICIPANT',
'VIEW_ROBOT_STATUS',
@@ -46,7 +45,6 @@ export const ROLE_PERMISSIONS: Record<RoleCode, Array<keyof typeof PERMISSIONS>>
WIZARD: [
'VIEW_STUDY',
'VIEW_PARTICIPANT_NAMES',
'VIEW_ROBOT_STATUS',
'CONTROL_ROBOT',
'RECORD_EXPERIMENT',