mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 22:54:45 -05:00
ability to create trials added; form uploader cleaned up
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.1 KiB |
@@ -1,9 +1,10 @@
|
|||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { participants } from "~/server/db/schema";
|
import { participants, trialParticipants, trials } from "~/server/db/schema";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const studyId = searchParams.get('studyId');
|
const studyId = searchParams.get('studyId');
|
||||||
|
|
||||||
@@ -11,8 +12,25 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Study ID is required' }, { status: 400 });
|
return NextResponse.json({ error: 'Study ID is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const allParticipants = await db.select().from(participants).where(eq(participants.studyId, parseInt(studyId)));
|
const participantsWithLatestTrial = await db
|
||||||
return NextResponse.json(allParticipants);
|
.select({
|
||||||
|
id: participants.id,
|
||||||
|
name: participants.name,
|
||||||
|
createdAt: participants.createdAt,
|
||||||
|
latestTrialTimestamp: sql<Date | null>`MAX(${trials.createdAt})`.as('latestTrialTimestamp')
|
||||||
|
})
|
||||||
|
.from(participants)
|
||||||
|
.leftJoin(trialParticipants, eq(participants.id, trialParticipants.participantId))
|
||||||
|
.leftJoin(trials, eq(trialParticipants.trialId, trials.id))
|
||||||
|
.where(eq(participants.studyId, parseInt(studyId)))
|
||||||
|
.groupBy(participants.id)
|
||||||
|
.orderBy(sql`COALESCE(MAX(${trials.createdAt}), ${participants.createdAt}) DESC`);
|
||||||
|
|
||||||
|
return NextResponse.json(participantsWithLatestTrial);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in GET /api/participants:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
|||||||
52
src/app/api/trials/route.ts
Normal file
52
src/app/api/trials/route.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { db } from "~/server/db";
|
||||||
|
import { trials, trialParticipants } from "~/server/db/schema";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const allTrials = await db
|
||||||
|
.select({
|
||||||
|
id: trials.id,
|
||||||
|
title: trials.title,
|
||||||
|
participantIds: sql`ARRAY_AGG(${trialParticipants.participantId})`.as('participantIds'),
|
||||||
|
})
|
||||||
|
.from(trials)
|
||||||
|
.leftJoin(trialParticipants, eq(trials.id, trialParticipants.trialId))
|
||||||
|
.groupBy(trials.id);
|
||||||
|
|
||||||
|
return NextResponse.json(allTrials);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { title, participantIds } = await request.json();
|
||||||
|
|
||||||
|
if (!title || !Array.isArray(participantIds) || participantIds.some(id => typeof id !== 'number')) {
|
||||||
|
return NextResponse.json({ error: 'Title and valid Participant IDs are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the new trial into the trials table
|
||||||
|
const newTrial = await db.insert(trials).values({ title }).returning();
|
||||||
|
// Check if newTrial is defined and has at least one element
|
||||||
|
if (!newTrial || newTrial.length === 0) {
|
||||||
|
throw new Error('Failed to create a new trial');
|
||||||
|
}
|
||||||
|
// Insert the participant associations into the trial_participants table
|
||||||
|
const trialId = newTrial[0]?.id; // Use optional chaining to safely get the ID of the newly created trial
|
||||||
|
if (trialId === undefined) {
|
||||||
|
throw new Error('Trial ID is undefined');
|
||||||
|
}
|
||||||
|
const trialParticipantEntries = participantIds.map(participantId => ({
|
||||||
|
trialId,
|
||||||
|
participantId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await db.insert(trialParticipants).values(trialParticipantEntries);
|
||||||
|
|
||||||
|
return NextResponse.json(newTrial[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
const { id } = await request.json();
|
||||||
|
await db.delete(trials).where(eq(trials.id, id));
|
||||||
|
return NextResponse.json({ message: "Trial deleted successfully" });
|
||||||
|
}
|
||||||
@@ -1,11 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Layout from "~/components/layout";
|
import Layout from "~/components/layout";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "~/components/ui/card";
|
import { Card, CardHeader, CardTitle, CardContent } from "~/components/ui/card";
|
||||||
|
import { useStudyContext } from '~/context/StudyContext';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Avatar, AvatarFallback } from "~/components/ui/avatar";
|
||||||
|
|
||||||
|
interface ParticipantWithTrial {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
latestTrialTimestamp: string | null;
|
||||||
|
createdAt: string; // Add createdAt to the interface
|
||||||
|
}
|
||||||
|
|
||||||
const DashboardPage: React.FC = () => {
|
const DashboardPage: React.FC = () => {
|
||||||
|
const { selectedStudy } = useStudyContext();
|
||||||
|
const [participants, setParticipants] = useState<ParticipantWithTrial[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchParticipants = async () => {
|
||||||
|
if (selectedStudy) {
|
||||||
|
const response = await fetch(`/api/participants?studyId=${selectedStudy.id}`);
|
||||||
|
const data = await response.json();
|
||||||
|
setParticipants(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchParticipants();
|
||||||
|
}, [selectedStudy]);
|
||||||
|
|
||||||
|
const formatDate = (dateString: string | null) => {
|
||||||
|
if (!dateString) return 'No trials yet';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout pageTitle="Dashboard">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Card>
|
<Card className="card-level-1">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Platform Information</CardTitle>
|
<CardTitle>Platform Information</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -13,12 +48,35 @@ const DashboardPage: React.FC = () => {
|
|||||||
{/* Add content for Platform Information */}
|
{/* Add content for Platform Information */}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card className="card-level-1">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Participants</CardTitle>
|
<CardTitle>Participants</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{/* Add content for Participants */}
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{participants.slice(0, 4).map(participant => (
|
||||||
|
<Card key={participant.id} className="card-level-2 p-3 px-4 flex items-center">
|
||||||
|
<Avatar className="mr-4">
|
||||||
|
<AvatarFallback>{participant.name.split(' ').map(n => n[0]).join('')}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold">{participant.name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Last trial: {formatDate(participant.latestTrialTimestamp)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{participants.length > 4 && (
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<Link href="/participants">
|
||||||
|
<Button variant="outline" className="text-blue-600 hover:underline">
|
||||||
|
View More Participants
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
import Layout from "~/components/layout";
|
import Layout from "~/components/layout";
|
||||||
import { FormsGrid } from "~/components/forms/FormsGrid";
|
import { FormsGrid } from "~/components/forms/FormsGrid";
|
||||||
import { UploadFormButton } from "~/components/forms/UploadFormButton";
|
import { UploadFormButton } from "~/components/forms/UploadFormButton";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "~/components/ui/card";
|
||||||
|
|
||||||
export default function FormsPage() {
|
export default function FormsPage() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout pageTitle="Forms">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<Card>
|
||||||
<h1 className="text-3xl font-bold">Forms</h1>
|
<CardHeader>
|
||||||
|
<CardTitle className="flex justify-between items-center">
|
||||||
|
<span>Forms</span>
|
||||||
<UploadFormButton />
|
<UploadFormButton />
|
||||||
</div>
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
<FormsGrid />
|
<FormsGrid />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ import { Participants } from "~/components/participant/Participants";
|
|||||||
|
|
||||||
const ParticipantsPage = () => {
|
const ParticipantsPage = () => {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout pageTitle="Participants">
|
||||||
<Participants />
|
<Participants />
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Studies } from "~/components/study/Studies";
|
|||||||
|
|
||||||
export default function StudiesPage() {
|
export default function StudiesPage() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout pageTitle="Studies">
|
||||||
<Studies />
|
<Studies />
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
10
src/app/trials/page.tsx
Normal file
10
src/app/trials/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import Layout from "~/components/layout";
|
||||||
|
import { Trials } from "~/components/trial/Trials";
|
||||||
|
|
||||||
|
export default function TrialsPage() {
|
||||||
|
return (
|
||||||
|
<Layout pageTitle="Trials">
|
||||||
|
<Trials />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import Image from 'next/image';
|
|||||||
import { Card, CardContent, CardFooter } from "~/components/ui/card";
|
import { Card, CardContent, CardFooter } from "~/components/ui/card";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
|
||||||
interface FormCardProps {
|
interface FormCardProps {
|
||||||
form: {
|
form: {
|
||||||
@@ -23,7 +24,7 @@ export function FormCard({ form, onDelete }: FormCardProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden cursor-pointer" onClick={handleCardClick}>
|
<Card className="overflow-hidden cursor-pointer" onClick={handleCardClick} style={{ backgroundColor: 'var(--primary-card-background)' }}>
|
||||||
<CardContent className="p-0 h-40 relative">
|
<CardContent className="p-0 h-40 relative">
|
||||||
<Image
|
<Image
|
||||||
src={form.previewLocation}
|
src={form.previewLocation}
|
||||||
@@ -36,16 +37,21 @@ export function FormCard({ form, onDelete }: FormCardProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex flex-col items-start p-4">
|
<CardFooter className="flex flex-col items-start p-4" style={{ backgroundColor: 'var(--secondary-card-background)' }}>
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
<h3 className="font-semibold mb-2">{form.title}</h3>
|
<h3 className="font-semibold mb-2">{form.title}</h3>
|
||||||
<Trash2
|
<Button
|
||||||
className="h-4 w-4 text-destructive cursor-pointer"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete(form.id);
|
onDelete(form.id);
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
<Badge variant="secondary">{form.studyTitle}</Badge>
|
<Badge variant="secondary">{form.studyTitle}</Badge>
|
||||||
|
|||||||
1
src/components/forms/Forms.tsx
Normal file
1
src/components/forms/Forms.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -3,13 +3,17 @@ import { Sidebar } from "~/components/sidebar";
|
|||||||
import { StudyHeader } from "~/components/study/StudyHeader";
|
import { StudyHeader } from "~/components/study/StudyHeader";
|
||||||
import { Toaster } from "~/components/ui/toaster";
|
import { Toaster } from "~/components/ui/toaster";
|
||||||
|
|
||||||
const Layout = ({ children }: PropsWithChildren) => {
|
interface LayoutProps {
|
||||||
|
pageTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Layout = ({ children, pageTitle }: PropsWithChildren<LayoutProps>) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen">
|
<div className="flex h-screen">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 overflow-y-auto bg-gradient-to-b from-[hsl(var(--gradient-start))] to-[hsl(var(--gradient-end))]">
|
<main className="flex-1 overflow-y-auto bg-gradient-to-b from-[hsl(var(--gradient-start))] to-[hsl(var(--gradient-end))]">
|
||||||
<div className="container mx-auto space-y-4 p-4 pt-16 lg:pt-4">
|
<div className="container mx-auto space-y-4 p-4 pt-16 lg:pt-4">
|
||||||
<StudyHeader />
|
<StudyHeader pageTitle={pageTitle} />
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,9 +8,17 @@ import { Participant } from '../../types/Participant';
|
|||||||
import { CreateParticipantDialog } from './CreateParticipantDialog';
|
import { CreateParticipantDialog } from './CreateParticipantDialog';
|
||||||
import { useToast } from '~/hooks/use-toast';
|
import { useToast } from '~/hooks/use-toast';
|
||||||
import { ParticipantCard } from './ParticipantCard';
|
import { ParticipantCard } from './ParticipantCard';
|
||||||
|
import { Avatar, AvatarFallback } from "~/components/ui/avatar";
|
||||||
|
|
||||||
|
interface ParticipantWithTrial {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
latestTrialTimestamp: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function Participants() {
|
export function Participants() {
|
||||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
const [participants, setParticipants] = useState<ParticipantWithTrial[]>([]);
|
||||||
const { selectedStudy } = useStudyContext();
|
const { selectedStudy } = useStudyContext();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -22,9 +30,23 @@ export function Participants() {
|
|||||||
|
|
||||||
const fetchParticipants = async () => {
|
const fetchParticipants = async () => {
|
||||||
if (!selectedStudy) return;
|
if (!selectedStudy) return;
|
||||||
|
try {
|
||||||
const response = await fetch(`/api/participants?studyId=${selectedStudy.id}`);
|
const response = await fetch(`/api/participants?studyId=${selectedStudy.id}`);
|
||||||
const data = await response.json();
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(text);
|
||||||
setParticipants(data);
|
setParticipants(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse JSON:', text);
|
||||||
|
throw new Error('Invalid JSON in response');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching participants:', error);
|
||||||
|
// Handle the error appropriately, e.g., show a toast notification
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const createParticipant = async (name: string) => {
|
const createParticipant = async (name: string) => {
|
||||||
@@ -41,19 +63,12 @@ export function Participants() {
|
|||||||
const deleteParticipant = async (id: number) => {
|
const deleteParticipant = async (id: number) => {
|
||||||
if (!selectedStudy) return;
|
if (!selectedStudy) return;
|
||||||
try {
|
try {
|
||||||
console.log(`Attempting to delete participant with ID: ${id}`);
|
|
||||||
const response = await fetch(`/api/participants/${id}`, {
|
const response = await fetch(`/api/participants/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
console.log('Delete response:', response);
|
|
||||||
|
|
||||||
const contentType = response.headers.get("content-type");
|
|
||||||
if (contentType && contentType.indexOf("application/json") !== -1) {
|
|
||||||
const result = await response.json();
|
|
||||||
console.log('Delete result:', result);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(result.error || `Failed to delete participant. Status: ${response.status}`);
|
throw new Error('Failed to delete participant');
|
||||||
}
|
}
|
||||||
|
|
||||||
setParticipants(participants.filter(p => p.id !== id));
|
setParticipants(participants.filter(p => p.id !== id));
|
||||||
@@ -61,13 +76,7 @@ export function Participants() {
|
|||||||
title: "Success",
|
title: "Success",
|
||||||
description: "Participant deleted successfully",
|
description: "Participant deleted successfully",
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
const text = await response.text();
|
|
||||||
console.error('Unexpected response:', text);
|
|
||||||
throw new Error(`Unexpected response from server. Status: ${response.status}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting participant:', error);
|
|
||||||
toast({
|
toast({
|
||||||
title: "Error",
|
title: "Error",
|
||||||
description: error instanceof Error ? error.message : 'Failed to delete participant',
|
description: error instanceof Error ? error.message : 'Failed to delete participant',
|
||||||
@@ -81,16 +90,36 @@ export function Participants() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="card-level-1">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-2xl font-bold">Participants for {selectedStudy.title}</CardTitle>
|
<CardTitle className="text-2xl font-bold">Participants for {selectedStudy.title}</CardTitle>
|
||||||
<CreateParticipantDialog onCreateParticipant={createParticipant} />
|
<CreateParticipantDialog onCreateParticipant={createParticipant} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{participants.length > 0 ? (
|
{participants.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
{participants.map(participant => (
|
{participants.map(participant => (
|
||||||
<ParticipantCard key={participant.id} participant={participant} onDelete={deleteParticipant} />
|
<Card key={participant.id} className="card-level-2 p-3 flex items-center w-full">
|
||||||
|
<Avatar className="mr-4">
|
||||||
|
<AvatarFallback>{participant.name.split(' ').map(n => n[0]).join('')}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold">{participant.name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{participant.latestTrialTimestamp
|
||||||
|
? `Last trial: ${new Date(participant.latestTrialTimestamp).toLocaleString()}`
|
||||||
|
: 'No trials yet'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => deleteParticipant(participant.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { UserButton, useUser } from "@clerk/nextjs"
|
import { UserButton, useUser } from "@clerk/nextjs"
|
||||||
import {
|
import {
|
||||||
BarChartIcon,
|
BarChartIcon,
|
||||||
BeakerIcon,
|
UsersRoundIcon,
|
||||||
|
LandPlotIcon,
|
||||||
BotIcon,
|
BotIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
@@ -22,7 +23,8 @@ import { ThemeToggle } from "~/components/ThemeToggle"
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: "Dashboard", href: "/dash", icon: LayoutDashboard },
|
{ name: "Dashboard", href: "/dash", icon: LayoutDashboard },
|
||||||
{ name: "Studies", href: "/studies", icon: FolderIcon },
|
{ name: "Studies", href: "/studies", icon: FolderIcon },
|
||||||
{ name: "Participants", href: "/participants", icon: BeakerIcon },
|
{ name: "Participants", href: "/participants", icon: UsersRoundIcon },
|
||||||
|
{ name: "Trials", href: "/trials", icon: LandPlotIcon },
|
||||||
{ name: "Forms", href: "/forms", icon: FileTextIcon },
|
{ name: "Forms", href: "/forms", icon: FileTextIcon },
|
||||||
{ name: "Data Analysis", href: "/analysis", icon: BarChartIcon },
|
{ name: "Data Analysis", href: "/analysis", icon: BarChartIcon },
|
||||||
{ name: "Settings", href: "/settings", icon: Settings },
|
{ name: "Settings", href: "/settings", icon: Settings },
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import { StudySelector } from './StudySelector';
|
|||||||
import { CreateStudyDialog } from '~/components/study/CreateStudyDialog';
|
import { CreateStudyDialog } from '~/components/study/CreateStudyDialog';
|
||||||
import { Study } from '~/types/Study';
|
import { Study } from '~/types/Study';
|
||||||
|
|
||||||
export function StudyHeader() {
|
interface StudyHeaderProps {
|
||||||
|
pageTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StudyHeader: React.FC<StudyHeaderProps> = ({ pageTitle }) => {
|
||||||
const { studies, selectedStudy, setSelectedStudy, validateAndSetSelectedStudy, fetchAndSetStudies } = useStudyContext();
|
const { studies, selectedStudy, setSelectedStudy, validateAndSetSelectedStudy, fetchAndSetStudies } = useStudyContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -53,7 +57,7 @@ export function StudyHeader() {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<h2 className="text-2xl font-bold truncate max-w-[200px]">
|
<h2 className="text-2xl font-bold truncate max-w-[200px]">
|
||||||
{selectedStudy ? selectedStudy.title : 'Select a Study'}
|
{pageTitle}
|
||||||
</h2>
|
</h2>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -72,4 +76,4 @@ export function StudyHeader() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
58
src/components/trial/CreateTrialDialog.tsx
Normal file
58
src/components/trial/CreateTrialDialog.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
|
||||||
|
interface CreateTrialDialogProps {
|
||||||
|
onCreateTrial: (title: string, participantIds: number[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateTrialDialog({ onCreateTrial }: CreateTrialDialogProps) {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [participantIds, setParticipantIds] = useState<string>('');
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
const ids = participantIds.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
|
||||||
|
if (title && ids.length > 0) {
|
||||||
|
onCreateTrial(title, ids);
|
||||||
|
setTitle('');
|
||||||
|
setParticipantIds('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline">Add Trial</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add New Trial</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="title" className="text-right">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="col-span-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="participants" className="text-right">Participant IDs (comma-separated)</Label>
|
||||||
|
<Input
|
||||||
|
id="participants"
|
||||||
|
value={participantIds}
|
||||||
|
onChange={(e) => setParticipantIds(e.target.value)}
|
||||||
|
className="col-span-3"
|
||||||
|
placeholder="e.g. 1, 2, 3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCreate}>Add Trial</Button>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/components/trial/Trials.tsx
Normal file
109
src/components/trial/Trials.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { useToast } from '~/hooks/use-toast';
|
||||||
|
import { CreateTrialDialog } from '~/components/trial/CreateTrialDialog';
|
||||||
|
|
||||||
|
interface Trial {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
participantIds: number[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Trials() {
|
||||||
|
const [trials, setTrials] = useState<Trial[]>([]);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTrials();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchTrials = async () => {
|
||||||
|
const response = await fetch('/api/trials');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Error fetching trials:', response.status, errorText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
console.warn('No trials found');
|
||||||
|
setTrials([]); // Set to an empty array if no trials are found
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTrials(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTrial = async (title: string, participantIds: number[]) => {
|
||||||
|
const response = await fetch('/api/trials', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title, participantIds }),
|
||||||
|
});
|
||||||
|
const newTrial = await response.json();
|
||||||
|
setTrials([...trials, newTrial]);
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Trial created successfully",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTrial = async (id: number) => {
|
||||||
|
const response = await fetch(`/api/trials/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setTrials(trials.filter(trial => trial.id !== id));
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Trial deleted successfully",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to delete trial",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-2xl font-bold">Trials</CardTitle>
|
||||||
|
<CreateTrialDialog onCreateTrial={createTrial} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{trials.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{trials.map(trial => (
|
||||||
|
<Card key={trial.id} className="bg-gray-100 p-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{trial.title}</h3>
|
||||||
|
<p className="text-sm text-gray-500">Participants: {trial.participantIds.join(', ')}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => deleteTrial(trial.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p>No trials added yet.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -90,3 +90,23 @@ export const users = pgTable(
|
|||||||
.notNull(),
|
.notNull(),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const trials = pgTable(
|
||||||
|
"trial",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
title: varchar("title", { length: 256 }).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
.notNull(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const trialParticipants = pgTable(
|
||||||
|
"trial_participants",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
trialId: integer("trial_id").references(() => trials.id).notNull(),
|
||||||
|
participantId: integer("participant_id").references(() => participants.id).notNull(),
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
:root {
|
:root {
|
||||||
--background: 210 50% 98%;
|
--background: 210 50% 98%;
|
||||||
--foreground: 215 25% 27%;
|
--foreground: 215 25% 27%;
|
||||||
--card: 210 50% 98%;
|
--card: 210 50% 98%; /* Card background color */
|
||||||
--card-foreground: 215 25% 27%;
|
--card-foreground: 215 25% 27%; /* Card text color */
|
||||||
--popover: 210 50% 98%;
|
--popover: 210 50% 98%;
|
||||||
--popover-foreground: 215 25% 27%;
|
--popover-foreground: 215 25% 27%;
|
||||||
--primary: 215 60% 40%;
|
--primary: 215 60% 40%;
|
||||||
@@ -35,39 +35,60 @@
|
|||||||
--sidebar-foreground: 215 25% 27%;
|
--sidebar-foreground: 215 25% 27%;
|
||||||
--sidebar-muted: 215 20% 50%;
|
--sidebar-muted: 215 20% 50%;
|
||||||
--sidebar-hover: 210 60% 86%;
|
--sidebar-hover: 210 60% 86%;
|
||||||
|
|
||||||
|
--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 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 220 20% 15%;
|
--background: 220 20% 15%; /* Dark mode background */
|
||||||
--foreground: 220 15% 85%;
|
--foreground: 220 20% 90%; /* Dark mode foreground */
|
||||||
--card: 220 20% 15%;
|
--card: 220 20% 15%; /* Dark mode card background color */
|
||||||
--card-foreground: 220 15% 85%;
|
--card-foreground: 220 20% 90%; /* Dark mode card text color */
|
||||||
--popover: 220 20% 15%;
|
--popover: 220 20% 15%;
|
||||||
--popover-foreground: 220 15% 85%;
|
--popover-foreground: 220 20% 90%;
|
||||||
--primary: 220 60% 50%;
|
--primary: 220 60% 50%;
|
||||||
--primary-foreground: 220 15% 85%;
|
--primary-foreground: 220 20% 90%;
|
||||||
--secondary: 220 25% 20%;
|
--secondary: 220 30% 20%; /* Darker secondary */
|
||||||
--secondary-foreground: 220 15% 85%;
|
--secondary-foreground: 220 20% 90%;
|
||||||
--muted: 220 25% 20%;
|
--muted: 220 30% 20%;
|
||||||
--muted-foreground: 220 15% 65%;
|
--muted-foreground: 220 20% 70%;
|
||||||
--accent: 220 25% 20%;
|
--accent: 220 30% 20%;
|
||||||
--accent-foreground: 220 15% 85%;
|
--accent-foreground: 220 20% 90%;
|
||||||
--destructive: 0 62% 30%;
|
--destructive: 0 62% 40%; /* Darker destructive */
|
||||||
--destructive-foreground: 220 15% 85%;
|
--destructive-foreground: 220 20% 90%;
|
||||||
--border: 220 25% 20%;
|
--border: 220 30% 20%;
|
||||||
--input: 220 25% 20%;
|
--input: 220 30% 20%;
|
||||||
--ring: 220 60% 50%;
|
--ring: 220 60% 50%;
|
||||||
|
|
||||||
/* Update gradient variables for dark mode */
|
/* Update gradient variables for dark mode */
|
||||||
--gradient-start: 220 20% 13%;
|
--gradient-start: 220 20% 12%;
|
||||||
--gradient-end: 220 20% 15%;
|
--gradient-end: 220 20% 15%;
|
||||||
|
|
||||||
/* Update sidebar variables for dark mode */
|
/* Update sidebar variables for dark mode */
|
||||||
--sidebar-background-top: 220 20% 15%;
|
--sidebar-background-top: 220 20% 15%;
|
||||||
--sidebar-background-bottom: 220 20% 12%;
|
--sidebar-background-bottom: 220 20% 12%;
|
||||||
--sidebar-foreground: 220 15% 85%;
|
--sidebar-foreground: 220 20% 90%;
|
||||||
--sidebar-muted: 220 15% 65%;
|
--sidebar-muted: 220 20% 70%;
|
||||||
--sidebar-hover: 220 25% 18%;
|
--sidebar-hover: 220 30% 20%;
|
||||||
|
|
||||||
|
--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 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add these utility classes */
|
||||||
|
.card-level-1 {
|
||||||
|
background-color: hsl(var(--card-level-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-level-2 {
|
||||||
|
background-color: hsl(var(--card-level-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-level-3 {
|
||||||
|
background-color: hsl(var(--card-level-3));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,25 +8,32 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
|
||||||
/* Strictness */
|
/* Strictness */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"checkJs": true,
|
"checkJs": true,
|
||||||
|
|
||||||
/* Bundled projects */
|
/* Bundled projects */
|
||||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"ES2022"
|
||||||
|
],
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"jsx": "preserve",
|
"jsx": "preserve", // or "react" for older versions
|
||||||
"plugins": [{ "name": "next" }],
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
|
|
||||||
/* Path Aliases */
|
/* Path Aliases */
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"~/*": ["./src/*"]
|
"~/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
@@ -38,5 +45,7 @@
|
|||||||
"**/*.js",
|
"**/*.js",
|
||||||
".next/types/**/*.ts"
|
".next/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user