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 { participants } from "~/server/db/schema";
|
||||
import { participants, trialParticipants, trials } from "~/server/db/schema";
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
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 });
|
||||
}
|
||||
|
||||
const allParticipants = await db.select().from(participants).where(eq(participants.studyId, parseInt(studyId)));
|
||||
return NextResponse.json(allParticipants);
|
||||
const participantsWithLatestTrial = await db
|
||||
.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) {
|
||||
|
||||
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 { 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 { 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 (
|
||||
<Layout>
|
||||
<Layout pageTitle="Dashboard">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<Card className="card-level-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Platform Information</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -13,12 +48,35 @@ const DashboardPage: React.FC = () => {
|
||||
{/* Add content for Platform Information */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card className="card-level-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Participants</CardTitle>
|
||||
</CardHeader>
|
||||
<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>
|
||||
</Card>
|
||||
<Card>
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import Layout from "~/components/layout";
|
||||
import { FormsGrid } from "~/components/forms/FormsGrid";
|
||||
import { UploadFormButton } from "~/components/forms/UploadFormButton";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "~/components/ui/card";
|
||||
|
||||
export default function FormsPage() {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">Forms</h1>
|
||||
<Layout pageTitle="Forms">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex justify-between items-center">
|
||||
<span>Forms</span>
|
||||
<UploadFormButton />
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormsGrid />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Participants } from "~/components/participant/Participants";
|
||||
|
||||
const ParticipantsPage = () => {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout pageTitle="Participants">
|
||||
<Participants />
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Studies } from "~/components/study/Studies";
|
||||
|
||||
export default function StudiesPage() {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout pageTitle="Studies">
|
||||
<Studies />
|
||||
</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 { Badge } from "~/components/ui/badge";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
interface FormCardProps {
|
||||
form: {
|
||||
@@ -23,7 +24,7 @@ export function FormCard({ form, onDelete }: FormCardProps) {
|
||||
};
|
||||
|
||||
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">
|
||||
<Image
|
||||
src={form.previewLocation}
|
||||
@@ -36,16 +37,21 @@ export function FormCard({ form, onDelete }: FormCardProps) {
|
||||
}}
|
||||
/>
|
||||
</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">
|
||||
<h3 className="font-semibold mb-2">{form.title}</h3>
|
||||
<Trash2
|
||||
className="h-4 w-4 text-destructive cursor-pointer"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(form.id);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
<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 { Toaster } from "~/components/ui/toaster";
|
||||
|
||||
const Layout = ({ children }: PropsWithChildren) => {
|
||||
interface LayoutProps {
|
||||
pageTitle: string;
|
||||
}
|
||||
|
||||
const Layout = ({ children, pageTitle }: PropsWithChildren<LayoutProps>) => {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<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">
|
||||
<StudyHeader />
|
||||
<StudyHeader pageTitle={pageTitle} />
|
||||
{children}
|
||||
<Toaster />
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,17 @@ import { Participant } from '../../types/Participant';
|
||||
import { CreateParticipantDialog } from './CreateParticipantDialog';
|
||||
import { useToast } from '~/hooks/use-toast';
|
||||
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() {
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
const [participants, setParticipants] = useState<ParticipantWithTrial[]>([]);
|
||||
const { selectedStudy } = useStudyContext();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -22,9 +30,23 @@ export function Participants() {
|
||||
|
||||
const fetchParticipants = async () => {
|
||||
if (!selectedStudy) return;
|
||||
try {
|
||||
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);
|
||||
} 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) => {
|
||||
@@ -41,19 +63,12 @@ export function Participants() {
|
||||
const deleteParticipant = async (id: number) => {
|
||||
if (!selectedStudy) return;
|
||||
try {
|
||||
console.log(`Attempting to delete participant with ID: ${id}`);
|
||||
const response = await fetch(`/api/participants/${id}`, {
|
||||
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) {
|
||||
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));
|
||||
@@ -61,13 +76,7 @@ export function Participants() {
|
||||
title: "Success",
|
||||
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) {
|
||||
console.error('Error deleting participant:', error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error instanceof Error ? error.message : 'Failed to delete participant',
|
||||
@@ -81,16 +90,36 @@ export function Participants() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="card-level-1">
|
||||
<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>
|
||||
<CreateParticipantDialog onCreateParticipant={createParticipant} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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 => (
|
||||
<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>
|
||||
) : (
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { UserButton, useUser } from "@clerk/nextjs"
|
||||
import {
|
||||
BarChartIcon,
|
||||
BeakerIcon,
|
||||
UsersRoundIcon,
|
||||
LandPlotIcon,
|
||||
BotIcon,
|
||||
FolderIcon,
|
||||
FileTextIcon,
|
||||
@@ -22,7 +23,8 @@ import { ThemeToggle } from "~/components/ThemeToggle"
|
||||
const navItems = [
|
||||
{ name: "Dashboard", href: "/dash", icon: LayoutDashboard },
|
||||
{ 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: "Data Analysis", href: "/analysis", icon: BarChartIcon },
|
||||
{ name: "Settings", href: "/settings", icon: Settings },
|
||||
|
||||
@@ -8,7 +8,11 @@ import { StudySelector } from './StudySelector';
|
||||
import { CreateStudyDialog } from '~/components/study/CreateStudyDialog';
|
||||
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();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -53,7 +57,7 @@ export function StudyHeader() {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<h2 className="text-2xl font-bold truncate max-w-[200px]">
|
||||
{selectedStudy ? selectedStudy.title : 'Select a Study'}
|
||||
{pageTitle}
|
||||
</h2>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -72,4 +76,4 @@ export function StudyHeader() {
|
||||
</CardContent>
|
||||
</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(),
|
||||
}
|
||||
);
|
||||
|
||||
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 {
|
||||
--background: 210 50% 98%;
|
||||
--foreground: 215 25% 27%;
|
||||
--card: 210 50% 98%;
|
||||
--card-foreground: 215 25% 27%;
|
||||
--card: 210 50% 98%; /* Card background color */
|
||||
--card-foreground: 215 25% 27%; /* Card text color */
|
||||
--popover: 210 50% 98%;
|
||||
--popover-foreground: 215 25% 27%;
|
||||
--primary: 215 60% 40%;
|
||||
@@ -35,39 +35,60 @@
|
||||
--sidebar-foreground: 215 25% 27%;
|
||||
--sidebar-muted: 215 20% 50%;
|
||||
--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 {
|
||||
--background: 220 20% 15%;
|
||||
--foreground: 220 15% 85%;
|
||||
--card: 220 20% 15%;
|
||||
--card-foreground: 220 15% 85%;
|
||||
--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 */
|
||||
--popover: 220 20% 15%;
|
||||
--popover-foreground: 220 15% 85%;
|
||||
--popover-foreground: 220 20% 90%;
|
||||
--primary: 220 60% 50%;
|
||||
--primary-foreground: 220 15% 85%;
|
||||
--secondary: 220 25% 20%;
|
||||
--secondary-foreground: 220 15% 85%;
|
||||
--muted: 220 25% 20%;
|
||||
--muted-foreground: 220 15% 65%;
|
||||
--accent: 220 25% 20%;
|
||||
--accent-foreground: 220 15% 85%;
|
||||
--destructive: 0 62% 30%;
|
||||
--destructive-foreground: 220 15% 85%;
|
||||
--border: 220 25% 20%;
|
||||
--input: 220 25% 20%;
|
||||
--primary-foreground: 220 20% 90%;
|
||||
--secondary: 220 30% 20%; /* Darker secondary */
|
||||
--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-foreground: 220 20% 90%;
|
||||
--border: 220 30% 20%;
|
||||
--input: 220 30% 20%;
|
||||
--ring: 220 60% 50%;
|
||||
|
||||
/* Update gradient variables for dark mode */
|
||||
--gradient-start: 220 20% 13%;
|
||||
--gradient-start: 220 20% 12%;
|
||||
--gradient-end: 220 20% 15%;
|
||||
|
||||
/* Update sidebar variables for dark mode */
|
||||
--sidebar-background-top: 220 20% 15%;
|
||||
--sidebar-background-bottom: 220 20% 12%;
|
||||
--sidebar-foreground: 220 15% 85%;
|
||||
--sidebar-muted: 220 15% 65%;
|
||||
--sidebar-hover: 220 25% 18%;
|
||||
--sidebar-foreground: 220 20% 90%;
|
||||
--sidebar-muted: 220 20% 70%;
|
||||
--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,
|
||||
"moduleDetection": "force",
|
||||
"isolatedModules": true,
|
||||
|
||||
/* Strictness */
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"checkJs": true,
|
||||
|
||||
/* Bundled projects */
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"ES2022"
|
||||
],
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve",
|
||||
"plugins": [{ "name": "next" }],
|
||||
"jsx": "preserve", // or "react" for older versions
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"incremental": true,
|
||||
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
"~/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -38,5 +45,7 @@
|
||||
"**/*.js",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user