mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 14:44:44 -05:00
Studies, basic experiment designer
This commit is contained in:
17
bun.lock
17
bun.lock
@@ -5,9 +5,13 @@
|
|||||||
"name": "hristudio",
|
"name": "hristudio",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/drizzle-adapter": "^1.10.0",
|
"@auth/drizzle-adapter": "^1.10.0",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
@@ -20,6 +24,7 @@
|
|||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.41.0",
|
"drizzle-orm": "^0.41.0",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "^15.2.3",
|
"next": "^15.2.3",
|
||||||
@@ -63,6 +68,14 @@
|
|||||||
|
|
||||||
"@auth/drizzle-adapter": ["@auth/drizzle-adapter@1.10.0", "", { "dependencies": { "@auth/core": "0.40.0" } }, "sha512-3MKsdAINTfvV4QKev8PFMNG93HJEUHh9sggDXnmUmriFogRf8qLvgqnPsTlfUyWcLwTzzrrYjeu8CGM+4IxHwQ=="],
|
"@auth/drizzle-adapter": ["@auth/drizzle-adapter@1.10.0", "", { "dependencies": { "@auth/core": "0.40.0" } }, "sha512-3MKsdAINTfvV4QKev8PFMNG93HJEUHh9sggDXnmUmriFogRf8qLvgqnPsTlfUyWcLwTzzrrYjeu8CGM+4IxHwQ=="],
|
||||||
|
|
||||||
|
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
||||||
|
|
||||||
|
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
|
||||||
|
|
||||||
|
"@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
|
||||||
|
|
||||||
|
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
|
||||||
|
|
||||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||||
|
|
||||||
"@emnapi/core": ["@emnapi/core@1.4.4", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.3", "tslib": "^2.4.0" } }, "sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g=="],
|
"@emnapi/core": ["@emnapi/core@1.4.4", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.3", "tslib": "^2.4.0" } }, "sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g=="],
|
||||||
@@ -279,6 +292,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.9", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A=="],
|
||||||
|
|
||||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="],
|
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="],
|
||||||
|
|
||||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
|
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
|
||||||
@@ -545,6 +560,8 @@
|
|||||||
|
|
||||||
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
|
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
|
||||||
|
|
||||||
|
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||||
|
|
||||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||||
|
|||||||
@@ -23,9 +23,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/drizzle-adapter": "^1.10.0",
|
"@auth/drizzle-adapter": "^1.10.0",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
@@ -38,6 +42,7 @@
|
|||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.41.0",
|
"drizzle-orm": "^0.41.0",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "^15.2.3",
|
"next": "^15.2.3",
|
||||||
|
|||||||
26
src/app/(dashboard)/experiments/[id]/designer/page.tsx
Normal file
26
src/app/(dashboard)/experiments/[id]/designer/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ExperimentDesignerClient } from "~/components/experiments/designer/ExperimentDesignerClient";
|
||||||
|
import { api } from "~/trpc/server";
|
||||||
|
|
||||||
|
interface ExperimentDesignerPageProps {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ExperimentDesignerPage({
|
||||||
|
params,
|
||||||
|
}: ExperimentDesignerPageProps) {
|
||||||
|
try {
|
||||||
|
const experiment = await api.experiments.get({ id: params.id });
|
||||||
|
|
||||||
|
if (!experiment) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ExperimentDesignerClient experiment={experiment} />;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading experiment:", error);
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
344
src/app/(dashboard)/experiments/new/page.tsx
Normal file
344
src/app/(dashboard)/experiments/new/page.tsx
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft, FlaskConical } from "lucide-react";
|
||||||
|
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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
|
const createExperimentSchema = z.object({
|
||||||
|
name: z.string().min(1, "Experiment name is required").max(100, "Name too long"),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.min(10, "Description must be at least 10 characters")
|
||||||
|
.max(1000, "Description too long"),
|
||||||
|
studyId: z.string().uuid("Please select a study"),
|
||||||
|
estimatedDuration: z
|
||||||
|
.number()
|
||||||
|
.min(1, "Duration must be at least 1 minute")
|
||||||
|
.max(480, "Duration cannot exceed 8 hours")
|
||||||
|
.optional(),
|
||||||
|
status: z.enum(["draft", "active", "completed", "archived"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
type CreateExperimentFormData = z.infer<typeof createExperimentSchema>;
|
||||||
|
|
||||||
|
export default function NewExperimentPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<CreateExperimentFormData>({
|
||||||
|
resolver: zodResolver(createExperimentSchema),
|
||||||
|
defaultValues: {
|
||||||
|
status: "draft" as const,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch user's studies for the dropdown
|
||||||
|
const { data: studiesData, isLoading: studiesLoading } = api.studies.list.useQuery(
|
||||||
|
{ memberOnly: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const createExperimentMutation = api.experiments.create.useMutation({
|
||||||
|
onSuccess: (experiment) => {
|
||||||
|
router.push(`/experiments/${experiment.id}/designer`);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Failed to create experiment:", error);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: CreateExperimentFormData) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await createExperimentMutation.mutateAsync({
|
||||||
|
...data,
|
||||||
|
estimatedDuration: data.estimatedDuration || null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Error handling is done in the mutation's onError callback
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const watchedStatus = watch("status");
|
||||||
|
const watchedStudyId = watch("studyId");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center space-x-2 text-sm text-slate-600 mb-4">
|
||||||
|
<Link href="/experiments" className="hover:text-slate-900 flex items-center">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
|
Experiments
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-slate-900">New Experiment</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-100">
|
||||||
|
<FlaskConical className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">Create New Experiment</h1>
|
||||||
|
<p className="text-slate-600">Design a new experimental protocol for your HRI study</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Main Form */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Experiment Details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Define the basic information for your experiment. You'll design the protocol steps next.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
{/* Experiment Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Experiment Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
{...register("name")}
|
||||||
|
placeholder="Enter experiment name..."
|
||||||
|
className={errors.name ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-sm text-red-600">{errors.name.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description *</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
{...register("description")}
|
||||||
|
placeholder="Describe the experiment objectives, methodology, and expected outcomes..."
|
||||||
|
rows={4}
|
||||||
|
className={errors.description ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.description && (
|
||||||
|
<p className="text-sm text-red-600">{errors.description.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Study Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="studyId">Study *</Label>
|
||||||
|
<Select
|
||||||
|
value={watchedStudyId}
|
||||||
|
onValueChange={(value) => setValue("studyId", value)}
|
||||||
|
disabled={studiesLoading}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={errors.studyId ? "border-red-500" : ""}>
|
||||||
|
<SelectValue placeholder={studiesLoading ? "Loading studies..." : "Select a study"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{studiesData?.studies?.map((study) => (
|
||||||
|
<SelectItem key={study.id} value={study.id}>
|
||||||
|
{study.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.studyId && (
|
||||||
|
<p className="text-sm text-red-600">{errors.studyId.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Estimated Duration */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="estimatedDuration">Estimated Duration (minutes)</Label>
|
||||||
|
<Input
|
||||||
|
id="estimatedDuration"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="480"
|
||||||
|
{...register("estimatedDuration", { valueAsNumber: true })}
|
||||||
|
placeholder="e.g., 30"
|
||||||
|
className={errors.estimatedDuration ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.estimatedDuration && (
|
||||||
|
<p className="text-sm text-red-600">{errors.estimatedDuration.message}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Optional: How long do you expect this experiment to take per participant?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="status">Initial Status</Label>
|
||||||
|
<Select
|
||||||
|
value={watchedStatus}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setValue("status", value as "draft" | "active" | "completed" | "archived")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="draft">Draft - Design in progress</SelectItem>
|
||||||
|
<SelectItem value="active">Active - Ready for trials</SelectItem>
|
||||||
|
<SelectItem value="completed">Completed - Data collection finished</SelectItem>
|
||||||
|
<SelectItem value="archived">Archived - Experiment concluded</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{createExperimentMutation.error && (
|
||||||
|
<div className="rounded-md bg-red-50 p-3">
|
||||||
|
<p className="text-sm text-red-800">
|
||||||
|
Failed to create experiment: {createExperimentMutation.error.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form Actions */}
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || studiesLoading}
|
||||||
|
className="min-w-[140px]"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Creating...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Create & Design"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Next Steps */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center space-x-2">
|
||||||
|
<FlaskConical className="h-5 w-5" />
|
||||||
|
<span>What's Next?</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="mt-1 h-2 w-2 rounded-full bg-blue-600"></div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Design Protocol</p>
|
||||||
|
<p className="text-slate-600">Use the visual designer to create experiment steps</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="mt-1 h-2 w-2 rounded-full bg-slate-300"></div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Configure Actions</p>
|
||||||
|
<p className="text-slate-600">Set up robot actions and wizard controls</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="mt-1 h-2 w-2 rounded-full bg-slate-300"></div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Test & Validate</p>
|
||||||
|
<p className="text-slate-600">Run test trials to verify the protocol</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="mt-1 h-2 w-2 rounded-full bg-slate-300"></div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Schedule Trials</p>
|
||||||
|
<p className="text-slate-600">Begin data collection with participants</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Tips */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>💡 Tips</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm text-slate-600">
|
||||||
|
<p>
|
||||||
|
<strong>Start simple:</strong> Begin with a basic protocol and add complexity later.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Plan interactions:</strong> Consider both robot behaviors and participant responses.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Test early:</strong> Validate your protocol with team members before recruiting participants.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/app/(dashboard)/experiments/page.tsx
Normal file
18
src/app/(dashboard)/experiments/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { ExperimentsGrid } from "~/components/experiments/ExperimentsGrid";
|
||||||
|
|
||||||
|
export default function ExperimentsPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">Experiments</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Design and manage experimental protocols for your HRI studies
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Experiments Grid */}
|
||||||
|
<ExperimentsGrid />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/app/(dashboard)/layout.tsx
Normal file
187
src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { auth } from "~/server/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
FlaskConical,
|
||||||
|
Play,
|
||||||
|
BarChart3,
|
||||||
|
Settings,
|
||||||
|
User,
|
||||||
|
LogOut,
|
||||||
|
Home,
|
||||||
|
UserCog,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface DashboardLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigationItems = [
|
||||||
|
{
|
||||||
|
label: "Studies",
|
||||||
|
href: "/studies",
|
||||||
|
icon: FlaskConical,
|
||||||
|
roles: ["administrator", "researcher", "wizard", "observer"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Experiments",
|
||||||
|
href: "/experiments",
|
||||||
|
icon: Settings,
|
||||||
|
roles: ["administrator", "researcher"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Trials",
|
||||||
|
href: "/trials",
|
||||||
|
icon: Play,
|
||||||
|
roles: ["administrator", "researcher", "wizard"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Analytics",
|
||||||
|
href: "/analytics",
|
||||||
|
icon: BarChart3,
|
||||||
|
roles: ["administrator", "researcher"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Participants",
|
||||||
|
href: "/participants",
|
||||||
|
icon: Users,
|
||||||
|
roles: ["administrator", "researcher"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const adminItems = [
|
||||||
|
{
|
||||||
|
label: "Administration",
|
||||||
|
href: "/admin",
|
||||||
|
icon: UserCog,
|
||||||
|
roles: ["administrator"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default async function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: DashboardLayoutProps) {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect("/auth/signin");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRole = session.user.roles[0]?.role || "observer";
|
||||||
|
const userName = session.user.name || session.user.email;
|
||||||
|
|
||||||
|
// Filter navigation items based on user role
|
||||||
|
const allowedNavItems = navigationItems.filter((item) =>
|
||||||
|
item.roles.includes(userRole),
|
||||||
|
);
|
||||||
|
const allowedAdminItems = adminItems.filter((item) =>
|
||||||
|
item.roles.includes(userRole),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="fixed inset-y-0 left-0 z-50 w-64 border-r border-slate-200 bg-white">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex h-16 items-center border-b border-slate-200 px-6">
|
||||||
|
<Link href="/" className="flex items-center space-x-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-600">
|
||||||
|
<FlaskConical className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold text-slate-900">HRIStudio</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<nav className="flex-1 space-y-2 px-4 py-6">
|
||||||
|
{/* Main Navigation */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{allowedNavItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="flex items-center space-x-3 rounded-lg px-3 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin Section */}
|
||||||
|
{allowedAdminItems.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="px-3 text-xs font-semibold tracking-wider text-slate-500 uppercase">
|
||||||
|
Administration
|
||||||
|
</h3>
|
||||||
|
{allowedAdminItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="flex items-center space-x-3 rounded-lg px-3 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User Section */}
|
||||||
|
<div className="border-t border-slate-200 p-4">
|
||||||
|
<div className="mb-3 flex items-center space-x-3">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100">
|
||||||
|
<User className="h-4 w-4 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-slate-900">
|
||||||
|
{userName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 capitalize">{userRole}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Link
|
||||||
|
href="/profile"
|
||||||
|
className="flex w-full items-center space-x-3 rounded-lg px-3 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
<span>Profile</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex w-full items-center space-x-3 rounded-lg px-3 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<Home className="h-4 w-4" />
|
||||||
|
<span>Home</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/auth/signout"
|
||||||
|
className="flex w-full items-center space-x-3 rounded-lg px-3 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
<span>Sign Out</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="pl-64">
|
||||||
|
<main className="min-h-screen">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
315
src/app/(dashboard)/studies/[id]/page.tsx
Normal file
315
src/app/(dashboard)/studies/[id]/page.tsx
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
FlaskConical,
|
||||||
|
Calendar,
|
||||||
|
Building,
|
||||||
|
Shield,
|
||||||
|
Settings,
|
||||||
|
Plus,
|
||||||
|
BarChart3,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { api } from "~/trpc/server";
|
||||||
|
|
||||||
|
interface StudyDetailPageProps {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
draft: {
|
||||||
|
label: "Draft",
|
||||||
|
className: "bg-gray-100 text-gray-800",
|
||||||
|
icon: "📝",
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
label: "Active",
|
||||||
|
className: "bg-green-100 text-green-800",
|
||||||
|
icon: "🟢",
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
label: "Completed",
|
||||||
|
className: "bg-blue-100 text-blue-800",
|
||||||
|
icon: "✅",
|
||||||
|
},
|
||||||
|
archived: {
|
||||||
|
label: "Archived",
|
||||||
|
className: "bg-orange-100 text-orange-800",
|
||||||
|
icon: "📦",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||||
|
try {
|
||||||
|
const study = await api.studies.get({ id: params.id });
|
||||||
|
const members = await api.studies.getMembers({ studyId: params.id });
|
||||||
|
|
||||||
|
if (!study) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusInfo = statusConfig[study.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center space-x-2 text-sm text-slate-600 mb-4">
|
||||||
|
<Link href="/studies" className="hover:text-slate-900">
|
||||||
|
Studies
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-slate-900">{study.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center space-x-3 mb-2">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900 truncate">
|
||||||
|
{study.name}
|
||||||
|
</h1>
|
||||||
|
<Badge className={statusInfo.className} variant="secondary">
|
||||||
|
<span className="mr-1">{statusInfo.icon}</span>
|
||||||
|
{statusInfo.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-600 text-lg">{study.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 ml-4">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/studies/${study.id}/edit`}>
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
Edit Study
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/studies/${study.id}/experiments/new`}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
New Experiment
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="lg:col-span-2 space-y-8">
|
||||||
|
{/* Study Information */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center space-x-2">
|
||||||
|
<Building className="h-5 w-5" />
|
||||||
|
<span>Study Information</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-slate-700">Institution</label>
|
||||||
|
<p className="text-slate-900">{study.institution}</p>
|
||||||
|
</div>
|
||||||
|
{study.irbProtocolNumber && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-slate-700">IRB Protocol</label>
|
||||||
|
<p className="text-slate-900">{study.irbProtocolNumber}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-slate-700">Created</label>
|
||||||
|
<p className="text-slate-900">
|
||||||
|
{formatDistanceToNow(study.createdAt, { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-slate-700">Last Updated</label>
|
||||||
|
<p className="text-slate-900">
|
||||||
|
{formatDistanceToNow(study.updatedAt, { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Experiments */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center space-x-2">
|
||||||
|
<FlaskConical className="h-5 w-5" />
|
||||||
|
<span>Experiments</span>
|
||||||
|
</CardTitle>
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href={`/studies/${study.id}/experiments/new`}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Experiment
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
Design and manage experimental protocols for this study
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* Placeholder for experiments list */}
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<FlaskConical className="h-12 w-12 text-slate-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-2">
|
||||||
|
No Experiments Yet
|
||||||
|
</h3>
|
||||||
|
<p className="text-slate-600 mb-4">
|
||||||
|
Create your first experiment to start designing research protocols
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/studies/${study.id}/experiments/new`}>
|
||||||
|
Create First Experiment
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center space-x-2">
|
||||||
|
<BarChart3 className="h-5 w-5" />
|
||||||
|
<span>Recent Activity</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Calendar className="h-12 w-12 text-slate-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-2">
|
||||||
|
No Recent Activity
|
||||||
|
</h3>
|
||||||
|
<p className="text-slate-600">
|
||||||
|
Activity will appear here once you start working on this study
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Team Members */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center space-x-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
<span>Team</span>
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Invite
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
{members.length} team member{members.length !== 1 ? 's' : ''}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{members.map((member) => (
|
||||||
|
<div key={member.user.id} className="flex items-center space-x-3">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100">
|
||||||
|
<span className="text-sm font-medium text-blue-600">
|
||||||
|
{(member.user.name || member.user.email).charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-900 truncate">
|
||||||
|
{member.user.name || member.user.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 capitalize">
|
||||||
|
{member.role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{member.role === "owner" && (
|
||||||
|
<Shield className="h-4 w-4 text-amber-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Quick Stats</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-slate-600">Experiments:</span>
|
||||||
|
<span className="font-medium">0</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-slate-600">Total Trials:</span>
|
||||||
|
<span className="font-medium">0</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-slate-600">Participants:</span>
|
||||||
|
<span className="font-medium">0</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-slate-600">Completion Rate:</span>
|
||||||
|
<span className="font-medium text-green-600">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Quick Actions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<Button asChild variant="outline" className="w-full justify-start">
|
||||||
|
<Link href={`/studies/${study.id}/participants`}>
|
||||||
|
<Users className="h-4 w-4 mr-2" />
|
||||||
|
Manage Participants
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline" className="w-full justify-start">
|
||||||
|
<Link href={`/studies/${study.id}/trials`}>
|
||||||
|
<Calendar className="h-4 w-4 mr-2" />
|
||||||
|
Schedule Trials
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline" className="w-full justify-start">
|
||||||
|
<Link href={`/studies/${study.id}/analytics`}>
|
||||||
|
<BarChart3 className="h-4 w-4 mr-2" />
|
||||||
|
View Analytics
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading study:", error);
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,155 +1,18 @@
|
|||||||
import { auth } from "~/server/auth";
|
import { StudiesGrid } from "~/components/studies/StudiesGrid";
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "~/components/ui/card";
|
|
||||||
|
|
||||||
export default async function StudiesPage() {
|
|
||||||
const session = await auth();
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
redirect("/auth/signin");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export default function StudiesPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
<div className="p-8">
|
||||||
<div className="container mx-auto px-4 py-8">
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8 flex items-center justify-between">
|
<div className="mb-8">
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-slate-900">Studies</h1>
|
<h1 className="text-3xl font-bold text-slate-900">Studies</h1>
|
||||||
<p className="text-slate-600">
|
<p className="mt-2 text-slate-600">
|
||||||
Manage your Human-Robot Interaction research studies
|
Manage your Human-Robot Interaction research studies
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-sm text-slate-600">
|
|
||||||
Welcome, {session.user.name ?? session.user.email}
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<Link href="/auth/signout">Sign Out</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href="/">← Back to Home</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Studies Grid */}
|
{/* Studies Grid */}
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<StudiesGrid />
|
||||||
{/* Create New Study Card */}
|
|
||||||
<Card className="border-2 border-dashed border-slate-300 transition-colors hover:border-slate-400">
|
|
||||||
<CardHeader className="text-center">
|
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-blue-100">
|
|
||||||
<svg
|
|
||||||
className="h-8 w-8 text-blue-600"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<CardTitle>Create New Study</CardTitle>
|
|
||||||
<CardDescription>Start a new HRI research study</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Button className="w-full" disabled>
|
|
||||||
Create Study
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Example Study Cards */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Robot Navigation Study</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Investigating user preferences for robot navigation patterns
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex justify-between text-sm text-slate-600">
|
|
||||||
<span>Created: Dec 2024</span>
|
|
||||||
<span>Status: Active</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" className="flex-1" disabled>
|
|
||||||
View Details
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" className="flex-1" disabled>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Social Robot Interaction</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Analyzing human responses to social robot behaviors
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex justify-between text-sm text-slate-600">
|
|
||||||
<span>Created: Nov 2024</span>
|
|
||||||
<span>Status: Draft</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" className="flex-1" disabled>
|
|
||||||
View Details
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" className="flex-1" disabled>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Empty State for No Studies */}
|
|
||||||
<div className="mt-12 text-center">
|
|
||||||
<div className="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-lg bg-slate-100">
|
|
||||||
<svg
|
|
||||||
className="h-12 w-12 text-slate-400"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
|
||||||
Authentication Test Successful!
|
|
||||||
</h3>
|
|
||||||
<p className="mb-4 text-slate-600">
|
|
||||||
You're viewing a protected page. The authentication system is
|
|
||||||
working correctly. This page will be replaced with actual study
|
|
||||||
management functionality.
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-slate-500">User ID: {session.user.id}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
361
src/components/experiments/ExperimentsGrid.tsx
Normal file
361
src/components/experiments/ExperimentsGrid.tsx
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Plus, FlaskConical, Settings, Calendar, Users } from "lucide-react";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
|
type ExperimentWithRelations = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
status: "draft" | "active" | "completed" | "archived";
|
||||||
|
estimatedDuration: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
studyId: string;
|
||||||
|
createdById: string;
|
||||||
|
study: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
createdBy: {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
_count?: {
|
||||||
|
steps: number;
|
||||||
|
trials: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
draft: {
|
||||||
|
label: "Draft",
|
||||||
|
className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
|
||||||
|
icon: "📝",
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
label: "Active",
|
||||||
|
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
||||||
|
icon: "🟢",
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
label: "Completed",
|
||||||
|
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
|
||||||
|
icon: "✅",
|
||||||
|
},
|
||||||
|
archived: {
|
||||||
|
label: "Archived",
|
||||||
|
className: "bg-orange-100 text-orange-800 hover:bg-orange-200",
|
||||||
|
icon: "📦",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ExperimentCardProps {
|
||||||
|
experiment: ExperimentWithRelations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExperimentCard({ experiment }: ExperimentCardProps) {
|
||||||
|
const statusInfo = statusConfig[experiment.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="group transition-all duration-200 hover:border-slate-300 hover:shadow-md">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<CardTitle className="truncate text-lg font-semibold text-slate-900 transition-colors group-hover:text-blue-600">
|
||||||
|
<Link
|
||||||
|
href={`/experiments/${experiment.id}`}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{experiment.name}
|
||||||
|
</Link>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1 line-clamp-2 text-sm text-slate-600">
|
||||||
|
{experiment.description}
|
||||||
|
</CardDescription>
|
||||||
|
<div className="mt-2 flex items-center text-xs text-slate-500">
|
||||||
|
<span>Study: </span>
|
||||||
|
<Link
|
||||||
|
href={`/studies/${experiment.study.id}`}
|
||||||
|
className="ml-1 font-medium text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
{experiment.study.name}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge className={statusInfo.className} variant="secondary">
|
||||||
|
<span className="mr-1">{statusInfo.icon}</span>
|
||||||
|
{statusInfo.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Experiment Metadata */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{experiment.estimatedDuration && (
|
||||||
|
<div className="flex items-center text-sm text-slate-600">
|
||||||
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
|
Estimated duration: {experiment.estimatedDuration} minutes
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center text-sm text-slate-600">
|
||||||
|
<Users className="mr-2 h-4 w-4" />
|
||||||
|
Created by {experiment.createdBy.name ?? experiment.createdBy.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics */}
|
||||||
|
{experiment._count && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-600">Steps:</span>
|
||||||
|
<span className="font-medium">{experiment._count.steps}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-600">Trials:</span>
|
||||||
|
<span className="font-medium">{experiment._count.trials}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-1 text-xs text-slate-500">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Created:</span>
|
||||||
|
<span>
|
||||||
|
{formatDistanceToNow(experiment.createdAt, { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{experiment.updatedAt !== experiment.createdAt && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Updated:</span>
|
||||||
|
<span>
|
||||||
|
{formatDistanceToNow(experiment.updatedAt, { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button asChild size="sm" className="flex-1">
|
||||||
|
<Link href={`/experiments/${experiment.id}`}>View Details</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="sm" variant="outline" className="flex-1">
|
||||||
|
<Link href={`/experiments/${experiment.id}/designer`}>
|
||||||
|
<Settings className="mr-1 h-3 w-3" />
|
||||||
|
Design
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExperimentsGrid() {
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: experimentsData,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = api.experiments.getUserExperiments.useQuery(
|
||||||
|
{ page: 1, limit: 50 },
|
||||||
|
{
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const experiments = experimentsData?.experiments ?? [];
|
||||||
|
|
||||||
|
const handleExperimentCreated = () => {
|
||||||
|
setRefreshKey((prev) => prev + 1);
|
||||||
|
void refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{/* Create Experiment Card */}
|
||||||
|
<Card className="border-2 border-dashed border-slate-300">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-blue-100">
|
||||||
|
<Plus className="h-8 w-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Create New Experiment</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Design a new experimental protocol
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<Link href="/experiments/new">Create Experiment</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Loading Skeletons */}
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Card key={i} className="animate-pulse">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-5 w-3/4 rounded bg-slate-200"></div>
|
||||||
|
<div className="h-4 w-full rounded bg-slate-200"></div>
|
||||||
|
<div className="h-4 w-2/3 rounded bg-slate-200"></div>
|
||||||
|
<div className="h-3 w-1/2 rounded bg-slate-200"></div>
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-16 rounded bg-slate-200"></div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-4 w-3/4 rounded bg-slate-200"></div>
|
||||||
|
<div className="h-4 w-1/2 rounded bg-slate-200"></div>
|
||||||
|
</div>
|
||||||
|
<div className="h-px bg-slate-200"></div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="h-3 rounded bg-slate-200"></div>
|
||||||
|
<div className="h-3 rounded bg-slate-200"></div>
|
||||||
|
</div>
|
||||||
|
<div className="h-px bg-slate-200"></div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="h-8 flex-1 rounded bg-slate-200"></div>
|
||||||
|
<div className="h-8 flex-1 rounded bg-slate-200"></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{/* Create Experiment Card */}
|
||||||
|
<Card className="border-2 border-dashed border-slate-300 transition-colors hover:border-slate-400">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-blue-100">
|
||||||
|
<Plus className="h-8 w-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Create New Experiment</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Design a new experimental protocol
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<Link href="/experiments/new">Create Experiment</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
<Card className="md:col-span-2">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-red-100">
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8 text-red-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
||||||
|
Failed to Load Experiments
|
||||||
|
</h3>
|
||||||
|
<p className="mb-4 text-slate-600">
|
||||||
|
{error.message ||
|
||||||
|
"An error occurred while loading your experiments."}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => refetch()} variant="outline">
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{/* Create New Experiment Card */}
|
||||||
|
<Card className="border-2 border-dashed border-slate-300 transition-colors hover:border-slate-400">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-blue-100">
|
||||||
|
<Plus className="h-8 w-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Create New Experiment</CardTitle>
|
||||||
|
<CardDescription>Design a new experimental protocol</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<Link href="/experiments/new">Create Experiment</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Experiments */}
|
||||||
|
{experiments.map((experiment) => (
|
||||||
|
<ExperimentCard key={experiment.id} experiment={experiment} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{experiments.length === 0 && (
|
||||||
|
<Card className="md:col-span-2 lg:col-span-2">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-lg bg-slate-100">
|
||||||
|
<FlaskConical className="h-12 w-12 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
||||||
|
No Experiments Yet
|
||||||
|
</h3>
|
||||||
|
<p className="mb-4 text-slate-600">
|
||||||
|
Create your first experiment to start designing HRI protocols.
|
||||||
|
Experiments define the structure and flow of your research
|
||||||
|
trials.
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/experiments/new">
|
||||||
|
Create Your First Experiment
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
589
src/components/experiments/designer/ExperimentDesigner.tsx
Normal file
589
src/components/experiments/designer/ExperimentDesigner.tsx
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef } from "react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
useDraggable,
|
||||||
|
useDroppable,
|
||||||
|
DragStartEvent,
|
||||||
|
DragEndEvent,
|
||||||
|
closestCenter,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Bot,
|
||||||
|
GitBranch,
|
||||||
|
Shuffle,
|
||||||
|
Settings,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Undo,
|
||||||
|
Redo,
|
||||||
|
Eye,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
|
|
||||||
|
// Step type definitions
|
||||||
|
export type StepType = "wizard" | "robot" | "parallel" | "conditional";
|
||||||
|
|
||||||
|
export interface ExperimentStep {
|
||||||
|
id: string;
|
||||||
|
type: StepType;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
order: number;
|
||||||
|
parameters: Record<string, any>;
|
||||||
|
duration?: number;
|
||||||
|
parentId?: string;
|
||||||
|
children?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExperimentDesign {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
steps: ExperimentStep[];
|
||||||
|
version: number;
|
||||||
|
lastSaved: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepTypeConfig = {
|
||||||
|
wizard: {
|
||||||
|
label: "Wizard Action",
|
||||||
|
description: "Manual control by wizard operator",
|
||||||
|
icon: Play,
|
||||||
|
color: "bg-blue-100 text-blue-700 border-blue-200",
|
||||||
|
defaultParams: {
|
||||||
|
instruction: "",
|
||||||
|
allowSkip: true,
|
||||||
|
timeout: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
robot: {
|
||||||
|
label: "Robot Action",
|
||||||
|
description: "Automated robot behavior",
|
||||||
|
icon: Bot,
|
||||||
|
color: "bg-green-100 text-green-700 border-green-200",
|
||||||
|
defaultParams: {
|
||||||
|
action: "",
|
||||||
|
parameters: {},
|
||||||
|
waitForCompletion: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parallel: {
|
||||||
|
label: "Parallel Steps",
|
||||||
|
description: "Execute multiple steps simultaneously",
|
||||||
|
icon: Shuffle,
|
||||||
|
color: "bg-purple-100 text-purple-700 border-purple-200",
|
||||||
|
defaultParams: {
|
||||||
|
waitForAll: true,
|
||||||
|
maxDuration: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditional: {
|
||||||
|
label: "Conditional Branch",
|
||||||
|
description: "Execute steps based on conditions",
|
||||||
|
icon: GitBranch,
|
||||||
|
color: "bg-orange-100 text-orange-700 border-orange-200",
|
||||||
|
defaultParams: {
|
||||||
|
condition: "",
|
||||||
|
trueSteps: [],
|
||||||
|
falseSteps: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StepLibraryItemProps {
|
||||||
|
type: StepType;
|
||||||
|
onDragStart?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepLibraryItem({ type, onDragStart }: StepLibraryItemProps) {
|
||||||
|
const config = stepTypeConfig[type];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
isDragging,
|
||||||
|
} = useDraggable({
|
||||||
|
id: `library-${type}`,
|
||||||
|
data: { type: "library-item", stepType: type },
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Translate.toString(transform),
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
className={`
|
||||||
|
cursor-grab active:cursor-grabbing
|
||||||
|
rounded-lg border-2 border-dashed p-3 transition-all
|
||||||
|
hover:border-solid hover:shadow-sm
|
||||||
|
${config.color}
|
||||||
|
`}
|
||||||
|
onMouseDown={onDragStart}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">{config.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs opacity-80">{config.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExperimentStepCardProps {
|
||||||
|
step: ExperimentStep;
|
||||||
|
onEdit: (step: ExperimentStep) => void;
|
||||||
|
onDelete: (stepId: string) => void;
|
||||||
|
isSelected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExperimentStepCard({
|
||||||
|
step,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
isSelected,
|
||||||
|
onClick
|
||||||
|
}: ExperimentStepCardProps) {
|
||||||
|
const config = stepTypeConfig[step.type];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id: step.id,
|
||||||
|
data: { type: "step", step },
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`
|
||||||
|
cursor-pointer transition-all
|
||||||
|
${isSelected ? "ring-2 ring-blue-500 shadow-md" : "hover:shadow-sm"}
|
||||||
|
`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className={`p-1 rounded ${config.color}`}>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-sm">{step.name}</CardTitle>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="drag-handle cursor-grab active:cursor-grabbing p-1"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-0.5">
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<div key={i} className="h-0.5 w-0.5 bg-slate-400 rounded-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
{step.description && (
|
||||||
|
<p className="text-xs text-slate-600 mb-2">{step.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2 text-xs text-slate-500">
|
||||||
|
<span>Step {step.order}</span>
|
||||||
|
{step.duration && <span>• {step.duration}s</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit(step);
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<Settings className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(step.id);
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExperimentCanvasProps {
|
||||||
|
steps: ExperimentStep[];
|
||||||
|
selectedStepId?: string;
|
||||||
|
onStepSelect: (stepId: string) => void;
|
||||||
|
onStepEdit: (step: ExperimentStep) => void;
|
||||||
|
onStepDelete: (stepId: string) => void;
|
||||||
|
onStepsReorder: (steps: ExperimentStep[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExperimentCanvas({
|
||||||
|
steps,
|
||||||
|
selectedStepId,
|
||||||
|
onStepSelect,
|
||||||
|
onStepEdit,
|
||||||
|
onStepDelete,
|
||||||
|
onStepsReorder,
|
||||||
|
}: ExperimentCanvasProps) {
|
||||||
|
const { setNodeRef } = useDroppable({
|
||||||
|
id: "experiment-canvas",
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepIds = steps.map((step) => step.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className="flex-1 bg-slate-50 rounded-lg border-2 border-dashed border-slate-300 p-4 min-h-[500px]"
|
||||||
|
>
|
||||||
|
{steps.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-center">
|
||||||
|
<div>
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-slate-200">
|
||||||
|
<Play className="h-8 w-8 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-700 mb-2">
|
||||||
|
Start Designing Your Experiment
|
||||||
|
</h3>
|
||||||
|
<p className="text-slate-500 max-w-sm">
|
||||||
|
Drag step types from the library on the left to begin creating your experimental protocol.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SortableContext items={stepIds} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{steps
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map((step) => (
|
||||||
|
<ExperimentStepCard
|
||||||
|
key={step.id}
|
||||||
|
step={step}
|
||||||
|
onEdit={onStepEdit}
|
||||||
|
onDelete={onStepDelete}
|
||||||
|
isSelected={selectedStepId === step.id}
|
||||||
|
onClick={() => onStepSelect(step.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExperimentDesignerProps {
|
||||||
|
experimentId: string;
|
||||||
|
initialDesign?: ExperimentDesign;
|
||||||
|
onSave?: (design: ExperimentDesign) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExperimentDesigner({
|
||||||
|
experimentId,
|
||||||
|
initialDesign,
|
||||||
|
onSave,
|
||||||
|
}: ExperimentDesignerProps) {
|
||||||
|
const [design, setDesign] = useState<ExperimentDesign>(
|
||||||
|
initialDesign || {
|
||||||
|
id: experimentId,
|
||||||
|
name: "New Experiment",
|
||||||
|
steps: [],
|
||||||
|
version: 1,
|
||||||
|
lastSaved: new Date(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const [selectedStepId, setSelectedStepId] = useState<string>();
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const createStep = useCallback((type: StepType): ExperimentStep => {
|
||||||
|
const config = stepTypeConfig[type];
|
||||||
|
const newOrder = Math.max(...design.steps.map(s => s.order), 0) + 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `step-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
type,
|
||||||
|
name: `${config.label} ${newOrder}`,
|
||||||
|
order: newOrder,
|
||||||
|
parameters: { ...config.defaultParams },
|
||||||
|
};
|
||||||
|
}, [design.steps]);
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
setActiveId(event.active.id as string);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
setActiveId(null);
|
||||||
|
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
// Handle dropping library item onto canvas
|
||||||
|
if (active.data.current?.type === "library-item" && over.id === "experiment-canvas") {
|
||||||
|
const stepType = active.data.current.stepType as StepType;
|
||||||
|
const newStep = createStep(stepType);
|
||||||
|
|
||||||
|
setDesign(prev => ({
|
||||||
|
...prev,
|
||||||
|
steps: [...prev.steps, newStep],
|
||||||
|
}));
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle reordering steps
|
||||||
|
if (active.data.current?.type === "step" && over.data.current?.type === "step") {
|
||||||
|
const activeStep = design.steps.find(s => s.id === active.id);
|
||||||
|
const overStep = design.steps.find(s => s.id === over.id);
|
||||||
|
|
||||||
|
if (!activeStep || !overStep) return;
|
||||||
|
|
||||||
|
const newSteps = [...design.steps];
|
||||||
|
const activeIndex = newSteps.findIndex(s => s.id === active.id);
|
||||||
|
const overIndex = newSteps.findIndex(s => s.id === over.id);
|
||||||
|
|
||||||
|
// Swap positions
|
||||||
|
[newSteps[activeIndex], newSteps[overIndex]] = [newSteps[overIndex], newSteps[activeIndex]];
|
||||||
|
|
||||||
|
// Update order numbers
|
||||||
|
newSteps.forEach((step, index) => {
|
||||||
|
step.order = index + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
setDesign(prev => ({
|
||||||
|
...prev,
|
||||||
|
steps: newSteps,
|
||||||
|
}));
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStepEdit = (step: ExperimentStep) => {
|
||||||
|
// TODO: Open step configuration modal
|
||||||
|
console.log("Edit step:", step);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStepDelete = (stepId: string) => {
|
||||||
|
setDesign(prev => ({
|
||||||
|
...prev,
|
||||||
|
steps: prev.steps.filter(s => s.id !== stepId),
|
||||||
|
}));
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
|
||||||
|
if (selectedStepId === stepId) {
|
||||||
|
setSelectedStepId(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!onSave || !hasUnsavedChanges) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const updatedDesign = {
|
||||||
|
...design,
|
||||||
|
lastSaved: new Date(),
|
||||||
|
version: design.version + 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
await onSave(updatedDesign);
|
||||||
|
setDesign(updatedDesign);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save experiment:", error);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b bg-white">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<h2 className="text-lg font-semibold">{design.name}</h2>
|
||||||
|
{hasUnsavedChanges && (
|
||||||
|
<Badge variant="outline" className="text-orange-600 border-orange-600">
|
||||||
|
Unsaved Changes
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button variant="outline" size="sm" disabled>
|
||||||
|
<Undo className="h-4 w-4 mr-1" />
|
||||||
|
Undo
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled>
|
||||||
|
<Redo className="h-4 w-4 mr-1" />
|
||||||
|
Redo
|
||||||
|
</Button>
|
||||||
|
<Separator orientation="vertical" className="h-6" />
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Eye className="h-4 w-4 mr-1" />
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasUnsavedChanges || isSaving}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4 mr-1" />
|
||||||
|
{isSaving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex">
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
{/* Step Library Sidebar */}
|
||||||
|
<div className="w-64 bg-white border-r p-4">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="font-semibold text-slate-900 mb-2">Step Library</h3>
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
Drag steps onto the canvas to build your experiment
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(Object.keys(stepTypeConfig) as StepType[]).map((type) => (
|
||||||
|
<StepLibraryItem key={type} type={type} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-4" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-slate-900 mb-2">Experiment Stats</h4>
|
||||||
|
<div className="space-y-1 text-sm text-slate-600">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Total Steps:</span>
|
||||||
|
<span className="font-medium">{design.steps.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Estimated Duration:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{design.steps.reduce((acc, step) => acc + (step.duration || 0), 0)}s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Version:</span>
|
||||||
|
<span className="font-medium">v{design.version}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Canvas */}
|
||||||
|
<div className="flex-1 p-4">
|
||||||
|
<ExperimentCanvas
|
||||||
|
steps={design.steps}
|
||||||
|
selectedStepId={selectedStepId}
|
||||||
|
onStepSelect={setSelectedStepId}
|
||||||
|
onStepEdit={handleStepEdit}
|
||||||
|
onStepDelete={handleStepDelete}
|
||||||
|
onStepsReorder={(newSteps) => {
|
||||||
|
setDesign(prev => ({ ...prev, steps: newSteps }));
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{activeId ? (
|
||||||
|
<div className="opacity-80">
|
||||||
|
{activeId.startsWith("library-") ? (
|
||||||
|
<div className="p-3 bg-white border rounded-lg shadow-lg">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">New Step</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="w-64 shadow-lg">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="font-medium text-sm">Moving step...</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/components/experiments/designer/ExperimentDesignerClient.tsx
Normal file
127
src/components/experiments/designer/ExperimentDesignerClient.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { ExperimentDesigner, ExperimentDesign } from "./ExperimentDesigner";
|
||||||
|
|
||||||
|
interface ExperimentDesignerClientProps {
|
||||||
|
experiment: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
studyId: string;
|
||||||
|
study?: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExperimentDesignerClient({ experiment }: ExperimentDesignerClientProps) {
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch the experiment's design data
|
||||||
|
const { data: experimentSteps, isLoading } = api.experiments.getSteps.useQuery({
|
||||||
|
experimentId: experiment.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveDesignMutation = api.experiments.saveDesign.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setSaveError(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setSaveError(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async (design: ExperimentDesign) => {
|
||||||
|
try {
|
||||||
|
await saveDesignMutation.mutateAsync({
|
||||||
|
experimentId: experiment.id,
|
||||||
|
steps: design.steps,
|
||||||
|
version: design.version,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save design:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<p className="text-slate-600">Loading experiment designer...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialDesign: ExperimentDesign = {
|
||||||
|
id: experiment.id,
|
||||||
|
name: experiment.name,
|
||||||
|
steps: experimentSteps || [],
|
||||||
|
version: 1,
|
||||||
|
lastSaved: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b bg-white">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Link
|
||||||
|
href={`/experiments/${experiment.id}`}
|
||||||
|
className="flex items-center text-sm text-slate-600 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
|
Back to Experiment
|
||||||
|
</Link>
|
||||||
|
<div className="h-4 w-px bg-slate-300" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-slate-900">
|
||||||
|
{experiment.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
Visual Protocol Designer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 text-sm text-slate-500">
|
||||||
|
<span>Study: </span>
|
||||||
|
<Link
|
||||||
|
href={`/studies/${experiment.studyId}`}
|
||||||
|
className="font-medium text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
{experiment.study?.name || "Unknown Study"}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{saveError && (
|
||||||
|
<div className="bg-red-50 border-l-4 border-red-400 p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className="text-sm text-red-700">
|
||||||
|
Failed to save experiment: {saveError}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Designer */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<ExperimentDesigner
|
||||||
|
experimentId={experiment.id}
|
||||||
|
initialDesign={initialDesign}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
282
src/components/studies/CreateStudyDialog.tsx
Normal file
282
src/components/studies/CreateStudyDialog.tsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
|
const createStudySchema = z.object({
|
||||||
|
name: z.string().min(1, "Study name is required").max(100, "Name too long"),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.min(10, "Description must be at least 10 characters")
|
||||||
|
.max(1000, "Description too long"),
|
||||||
|
irbProtocolNumber: z.string().optional(),
|
||||||
|
institution: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Institution is required")
|
||||||
|
.max(100, "Institution name too long"),
|
||||||
|
status: z.enum(["draft", "active", "completed", "archived"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
type CreateStudyFormData = z.infer<typeof createStudySchema>;
|
||||||
|
|
||||||
|
interface CreateStudyDialogProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateStudyDialog({
|
||||||
|
children,
|
||||||
|
onSuccess,
|
||||||
|
}: CreateStudyDialogProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<CreateStudyFormData>({
|
||||||
|
resolver: zodResolver(createStudySchema),
|
||||||
|
defaultValues: {
|
||||||
|
status: "draft" as const,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createStudyMutation = api.studies.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setOpen(false);
|
||||||
|
reset();
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
console.error("Failed to create study:", err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: CreateStudyFormData) => {
|
||||||
|
try {
|
||||||
|
await createStudyMutation.mutateAsync(data);
|
||||||
|
} catch (error) {
|
||||||
|
// Error handling is done in the mutation's onError callback
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const watchedStatus = watch("status");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Study</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Start a new Human-Robot Interaction research study. You'll be
|
||||||
|
assigned as the study owner.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
{/* Study Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Study Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
{...register("name")}
|
||||||
|
placeholder="Enter study name..."
|
||||||
|
className={errors.name ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-sm text-red-600">{errors.name.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description *</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
{...register("description")}
|
||||||
|
placeholder="Describe your research study, objectives, and methodology..."
|
||||||
|
rows={4}
|
||||||
|
className={errors.description ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.description && (
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
{errors.description.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Institution */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="institution">Institution *</Label>
|
||||||
|
<Input
|
||||||
|
id="institution"
|
||||||
|
{...register("institution")}
|
||||||
|
placeholder="University or research institution..."
|
||||||
|
className={errors.institution ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.institution && (
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
{errors.institution.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* IRB Protocol Number */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="irbProtocolNumber">IRB Protocol Number</Label>
|
||||||
|
<Input
|
||||||
|
id="irbProtocolNumber"
|
||||||
|
{...register("irbProtocolNumber")}
|
||||||
|
placeholder="Optional IRB protocol number..."
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
If your study has been approved by an Institutional Review Board
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="status">Initial Status</Label>
|
||||||
|
<Select
|
||||||
|
value={watchedStatus}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setValue(
|
||||||
|
"status",
|
||||||
|
value as "draft" | "active" | "completed" | "archived",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="draft">Draft - Planning stage</SelectItem>
|
||||||
|
<SelectItem value="active">
|
||||||
|
Active - Recruiting participants
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="completed">
|
||||||
|
Completed - Data collection finished
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="archived">
|
||||||
|
Archived - Study concluded
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-blue-100">
|
||||||
|
<svg
|
||||||
|
className="h-3 w-3 text-blue-600"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
<p className="text-foreground font-medium">
|
||||||
|
What happens next?
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1 space-y-1 text-xs">
|
||||||
|
<li>• You'll be assigned as the study owner</li>
|
||||||
|
<li>• You can invite team members and assign roles</li>
|
||||||
|
<li>• Start designing experiments and protocols</li>
|
||||||
|
<li>• Schedule trials and manage participants</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{createStudyMutation.error && (
|
||||||
|
<div className="rounded-md bg-red-50 p-3">
|
||||||
|
<p className="text-sm text-red-800">
|
||||||
|
Failed to create study: {createStudyMutation.error.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form Actions */}
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="min-w-[100px]"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 animate-spin"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Creating...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Create Study"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
310
src/components/studies/StudiesGrid.tsx
Normal file
310
src/components/studies/StudiesGrid.tsx
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { CreateStudyDialog } from "./CreateStudyDialog";
|
||||||
|
import { StudyCard } from "./StudyCard";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
|
type StudyWithRelations = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
status: "draft" | "active" | "completed" | "archived";
|
||||||
|
institution: string;
|
||||||
|
irbProtocolNumber: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
ownerId: string;
|
||||||
|
createdBy: {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
members: Array<{
|
||||||
|
role: "owner" | "researcher" | "wizard" | "observer";
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
experiments?: Array<{ id: string }>;
|
||||||
|
participants?: Array<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProcessedStudy = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
status: "draft" | "active" | "completed" | "archived";
|
||||||
|
institution: string;
|
||||||
|
irbProtocolNumber?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
ownerId: string;
|
||||||
|
owner: {
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
userRole?: "owner" | "researcher" | "wizard" | "observer";
|
||||||
|
isOwner?: boolean;
|
||||||
|
_count?: {
|
||||||
|
experiments: number;
|
||||||
|
trials: number;
|
||||||
|
studyMembers: number;
|
||||||
|
participants: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StudiesGrid() {
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const { data: session } = api.auth.me.useQuery();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: studiesData,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = api.studies.list.useQuery(
|
||||||
|
{ memberOnly: true },
|
||||||
|
{
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const processStudies = (
|
||||||
|
rawStudies: StudyWithRelations[],
|
||||||
|
): ProcessedStudy[] => {
|
||||||
|
const currentUserId = session?.id;
|
||||||
|
|
||||||
|
return rawStudies.map((study) => {
|
||||||
|
// Find current user's membership
|
||||||
|
const userMembership = study.members?.find(
|
||||||
|
(member) => member.user.id === currentUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: study.id,
|
||||||
|
name: study.name,
|
||||||
|
description: study.description,
|
||||||
|
status: study.status,
|
||||||
|
institution: study.institution,
|
||||||
|
irbProtocolNumber: study.irbProtocolNumber ?? undefined,
|
||||||
|
createdAt: study.createdAt,
|
||||||
|
updatedAt: study.updatedAt,
|
||||||
|
ownerId: study.ownerId,
|
||||||
|
owner: {
|
||||||
|
name: study.createdBy.name,
|
||||||
|
email: study.createdBy.email,
|
||||||
|
},
|
||||||
|
userRole: userMembership?.role,
|
||||||
|
isOwner: study.ownerId === currentUserId,
|
||||||
|
_count: {
|
||||||
|
experiments: study.experiments?.length ?? 0,
|
||||||
|
trials: 0, // Will be populated when trials relation is added
|
||||||
|
studyMembers: study.members?.length ?? 0,
|
||||||
|
participants: study.participants?.length ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const studies = studiesData?.studies
|
||||||
|
? processStudies(studiesData.studies)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const handleStudyCreated = () => {
|
||||||
|
setRefreshKey((prev) => prev + 1);
|
||||||
|
void refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{/* Create Study Card Skeleton */}
|
||||||
|
<Card className="border-2 border-dashed border-slate-300">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-blue-100">
|
||||||
|
<Plus className="h-8 w-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Create New Study</CardTitle>
|
||||||
|
<CardDescription>Start a new HRI research study</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CreateStudyDialog onSuccess={handleStudyCreated}>
|
||||||
|
<Button className="w-full">Create Study</Button>
|
||||||
|
</CreateStudyDialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Loading Skeletons */}
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Card key={i} className="animate-pulse">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-5 w-3/4 rounded bg-slate-200"></div>
|
||||||
|
<div className="h-4 w-full rounded bg-slate-200"></div>
|
||||||
|
<div className="h-4 w-2/3 rounded bg-slate-200"></div>
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-16 rounded bg-slate-200"></div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-4 w-3/4 rounded bg-slate-200"></div>
|
||||||
|
<div className="h-4 w-1/2 rounded bg-slate-200"></div>
|
||||||
|
</div>
|
||||||
|
<div className="h-px bg-slate-200"></div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-3 rounded bg-slate-200"></div>
|
||||||
|
<div className="h-3 rounded bg-slate-200"></div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-3 rounded bg-slate-200"></div>
|
||||||
|
<div className="h-3 rounded bg-slate-200"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-px bg-slate-200"></div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="h-8 flex-1 rounded bg-slate-200"></div>
|
||||||
|
<div className="h-8 flex-1 rounded bg-slate-200"></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{/* Create Study Card */}
|
||||||
|
<Card className="border-2 border-dashed border-slate-300 transition-colors hover:border-slate-400">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-blue-100">
|
||||||
|
<Plus className="h-8 w-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Create New Study</CardTitle>
|
||||||
|
<CardDescription>Start a new HRI research study</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CreateStudyDialog onSuccess={handleStudyCreated}>
|
||||||
|
<Button className="w-full">Create Study</Button>
|
||||||
|
</CreateStudyDialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
<Card className="md:col-span-2">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-red-100">
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8 text-red-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
||||||
|
Failed to Load Studies
|
||||||
|
</h3>
|
||||||
|
<p className="mb-4 text-slate-600">
|
||||||
|
{error.message ||
|
||||||
|
"An error occurred while loading your studies."}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => refetch()} variant="outline">
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{/* Create New Study Card */}
|
||||||
|
<Card className="border-2 border-dashed border-slate-300 transition-colors hover:border-slate-400">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-blue-100">
|
||||||
|
<Plus className="h-8 w-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Create New Study</CardTitle>
|
||||||
|
<CardDescription>Start a new HRI research study</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CreateStudyDialog onSuccess={handleStudyCreated}>
|
||||||
|
<Button className="w-full">Create Study</Button>
|
||||||
|
</CreateStudyDialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Studies */}
|
||||||
|
{studies.map((study) => (
|
||||||
|
<StudyCard
|
||||||
|
key={study.id}
|
||||||
|
study={study}
|
||||||
|
userRole={study.userRole}
|
||||||
|
isOwner={study.isOwner}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{studies.length === 0 && (
|
||||||
|
<Card className="md:col-span-2 lg:col-span-2">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-lg bg-slate-100">
|
||||||
|
<svg
|
||||||
|
className="h-12 w-12 text-slate-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
||||||
|
No Studies Yet
|
||||||
|
</h3>
|
||||||
|
<p className="mb-4 text-slate-600">
|
||||||
|
Get started by creating your first Human-Robot Interaction
|
||||||
|
research study. Studies help you organize experiments, manage
|
||||||
|
participants, and collaborate with your team.
|
||||||
|
</p>
|
||||||
|
<CreateStudyDialog onSuccess={handleStudyCreated}>
|
||||||
|
<Button>Create Your First Study</Button>
|
||||||
|
</CreateStudyDialog>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
src/components/studies/StudyCard.tsx
Normal file
215
src/components/studies/StudyCard.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
|
||||||
|
interface Study {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
status: "draft" | "active" | "completed" | "archived";
|
||||||
|
institution: string;
|
||||||
|
irbProtocolNumber?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
ownerId: string;
|
||||||
|
_count?: {
|
||||||
|
experiments: number;
|
||||||
|
trials: number;
|
||||||
|
studyMembers: number;
|
||||||
|
participants: number;
|
||||||
|
};
|
||||||
|
owner: {
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StudyCardProps {
|
||||||
|
study: Study;
|
||||||
|
userRole?: "owner" | "researcher" | "wizard" | "observer";
|
||||||
|
isOwner?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
draft: {
|
||||||
|
label: "Draft",
|
||||||
|
className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
|
||||||
|
icon: "📝",
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
label: "Active",
|
||||||
|
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
||||||
|
icon: "🟢",
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
label: "Completed",
|
||||||
|
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
|
||||||
|
icon: "✅",
|
||||||
|
},
|
||||||
|
archived: {
|
||||||
|
label: "Archived",
|
||||||
|
className: "bg-orange-100 text-orange-800 hover:bg-orange-200",
|
||||||
|
icon: "📦",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StudyCard({ study, userRole, isOwner }: StudyCardProps) {
|
||||||
|
const statusInfo = statusConfig[study.status];
|
||||||
|
const canEdit =
|
||||||
|
isOwner ?? (userRole === "owner" || userRole === "researcher");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="group transition-all duration-200 hover:border-slate-300 hover:shadow-md">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<CardTitle className="truncate text-lg font-semibold text-slate-900 transition-colors group-hover:text-blue-600">
|
||||||
|
<Link href={`/studies/${study.id}`} className="hover:underline">
|
||||||
|
{study.name}
|
||||||
|
</Link>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1 line-clamp-2 text-sm text-slate-600">
|
||||||
|
{study.description}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge className={statusInfo.className} variant="secondary">
|
||||||
|
<span className="mr-1">{statusInfo.icon}</span>
|
||||||
|
{statusInfo.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Institution and IRB */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center text-sm text-slate-600">
|
||||||
|
<svg
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-4m-5 0H3m2 0h4M9 7h6m-6 4h6m-6 4h6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{study.institution}
|
||||||
|
</div>
|
||||||
|
{study.irbProtocolNumber && (
|
||||||
|
<div className="flex items-center text-sm text-slate-500">
|
||||||
|
<svg
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
IRB: {study.irbProtocolNumber}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics */}
|
||||||
|
{study._count && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-600">Experiments:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{study._count.experiments}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-600">Trials:</span>
|
||||||
|
<span className="font-medium">{study._count.trials}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-600">Team:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{study._count.studyMembers}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-600">Participants:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{study._count.participants}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-1 text-xs text-slate-500">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Created:</span>
|
||||||
|
<span>
|
||||||
|
{formatDistanceToNow(study.createdAt, { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Owner:</span>
|
||||||
|
<span className="ml-2 truncate">
|
||||||
|
{study.owner.name ?? study.owner.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{study.updatedAt !== study.createdAt && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Updated:</span>
|
||||||
|
<span>
|
||||||
|
{formatDistanceToNow(study.updatedAt, { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button asChild size="sm" className="flex-1">
|
||||||
|
<Link href={`/studies/${study.id}`}>View Details</Link>
|
||||||
|
</Button>
|
||||||
|
{canEdit && (
|
||||||
|
<Button asChild size="sm" variant="outline" className="flex-1">
|
||||||
|
<Link href={`/studies/${study.id}/edit`}>Edit</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role indicator */}
|
||||||
|
{userRole && (
|
||||||
|
<div className="flex items-center justify-center pt-1">
|
||||||
|
<span className="text-xs text-slate-500 capitalize">
|
||||||
|
Your role: <span className="font-medium">{userRole}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/ui/scroll-area.tsx
Normal file
58
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { and, eq, desc, asc, inArray } from "drizzle-orm";
|
import { and, eq, desc, asc, inArray, count } from "drizzle-orm";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||||
import type { db } from "~/server/db";
|
import type { db } from "~/server/db";
|
||||||
@@ -109,6 +110,111 @@ export const experimentsRouter = createTRPCRouter({
|
|||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getUserExperiments: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
page: z.number().min(1).default(1),
|
||||||
|
limit: z.number().min(1).max(100).default(20),
|
||||||
|
status: z.enum(experimentStatusEnum.enumValues).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const { page, limit, status } = input;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const userId = ctx.session.user.id;
|
||||||
|
|
||||||
|
// Get all studies user is a member of
|
||||||
|
const userStudies = await ctx.db.query.studyMembers.findMany({
|
||||||
|
where: eq(studyMembers.userId, userId),
|
||||||
|
columns: {
|
||||||
|
studyId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const studyIds = userStudies.map((membership) => membership.studyId);
|
||||||
|
|
||||||
|
if (studyIds.length === 0) {
|
||||||
|
return {
|
||||||
|
experiments: [],
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total: 0,
|
||||||
|
pages: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build where conditions
|
||||||
|
const conditions = [inArray(experiments.studyId, studyIds)];
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
conditions.push(eq(experiments.status, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = and(...conditions);
|
||||||
|
|
||||||
|
// Get experiments with relations
|
||||||
|
const userExperiments = await ctx.db.query.experiments.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
with: {
|
||||||
|
study: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdBy: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
steps: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trials: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
orderBy: [desc(experiments.updatedAt)],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const totalCountResult = await ctx.db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(experiments)
|
||||||
|
.where(whereClause);
|
||||||
|
|
||||||
|
const totalCount = totalCountResult[0]?.count ?? 0;
|
||||||
|
|
||||||
|
// Transform data to include counts
|
||||||
|
const transformedExperiments = userExperiments.map((experiment) => ({
|
||||||
|
...experiment,
|
||||||
|
_count: {
|
||||||
|
steps: experiment.steps.length,
|
||||||
|
trials: experiment.trials.length,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
experiments: transformedExperiments,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total: totalCount,
|
||||||
|
pages: Math.ceil(totalCount / limit),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
get: protectedProcedure
|
get: protectedProcedure
|
||||||
.input(z.object({ id: z.string().uuid() }))
|
.input(z.object({ id: z.string().uuid() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
@@ -988,7 +1094,10 @@ export const experimentsRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === "wait" && !(action.parameters as { duration?: number })?.duration) {
|
if (
|
||||||
|
action.type === "wait" &&
|
||||||
|
!(action.parameters as { duration?: number })?.duration
|
||||||
|
) {
|
||||||
errors.push({
|
errors.push({
|
||||||
type: "missing_duration",
|
type: "missing_duration",
|
||||||
message: `Wait action "${action.name}" missing duration parameter`,
|
message: `Wait action "${action.name}" missing duration parameter`,
|
||||||
@@ -1015,4 +1124,177 @@ export const experimentsRouter = createTRPCRouter({
|
|||||||
warnings,
|
warnings,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getSteps: protectedProcedure
|
||||||
|
.input(z.object({ experimentId: z.string().uuid() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.session.user.id;
|
||||||
|
|
||||||
|
// First verify user has access to this experiment
|
||||||
|
const experiment = await ctx.db.query.experiments.findFirst({
|
||||||
|
where: eq(experiments.id, input.experimentId),
|
||||||
|
with: {
|
||||||
|
study: {
|
||||||
|
with: {
|
||||||
|
members: {
|
||||||
|
where: eq(studyMembers.userId, userId),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!experiment || experiment.study.members.length === 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Access denied to this experiment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get steps with their actions
|
||||||
|
const experimentSteps = await ctx.db.query.steps.findMany({
|
||||||
|
where: eq(steps.experimentId, input.experimentId),
|
||||||
|
with: {
|
||||||
|
actions: {
|
||||||
|
orderBy: [asc(actions.orderIndex)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [asc(steps.orderIndex)],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform to designer format
|
||||||
|
return experimentSteps.map((step) => ({
|
||||||
|
id: step.id,
|
||||||
|
type: step.type as "wizard" | "robot" | "parallel" | "conditional",
|
||||||
|
name: step.name,
|
||||||
|
description: step.description,
|
||||||
|
order: step.orderIndex,
|
||||||
|
duration: step.durationEstimate,
|
||||||
|
parameters: step.conditions as Record<string, any>,
|
||||||
|
parentId: undefined, // Not supported in current schema
|
||||||
|
children: [], // TODO: implement hierarchical steps if needed
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
|
||||||
|
saveDesign: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
experimentId: z.string().uuid(),
|
||||||
|
steps: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
type: z.enum(["wizard", "robot", "parallel", "conditional"]),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
order: z.number(),
|
||||||
|
duration: z.number().optional(),
|
||||||
|
parameters: z.record(z.any()),
|
||||||
|
parentId: z.string().optional(),
|
||||||
|
children: z.array(z.string()).optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
version: z.number(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.session.user.id;
|
||||||
|
|
||||||
|
// Verify user has write access to this experiment
|
||||||
|
const experiment = await ctx.db.query.experiments.findFirst({
|
||||||
|
where: eq(experiments.id, input.experimentId),
|
||||||
|
with: {
|
||||||
|
study: {
|
||||||
|
with: {
|
||||||
|
members: {
|
||||||
|
where: and(
|
||||||
|
eq(studyMembers.userId, userId),
|
||||||
|
inArray(studyMembers.role, ["owner", "researcher"] as const),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!experiment || experiment.study.members.length === 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Access denied to modify this experiment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing steps
|
||||||
|
const existingSteps = await ctx.db.query.steps.findMany({
|
||||||
|
where: eq(steps.experimentId, input.experimentId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingStepIds = new Set(existingSteps.map((s) => s.id));
|
||||||
|
const newStepIds = new Set(input.steps.map((s) => s.id));
|
||||||
|
|
||||||
|
// Steps to delete (exist in DB but not in input)
|
||||||
|
const stepsToDelete = existingSteps.filter((s) => !newStepIds.has(s.id));
|
||||||
|
|
||||||
|
// Steps to insert (in input but don't exist in DB or have temp IDs)
|
||||||
|
const stepsToInsert = input.steps.filter(
|
||||||
|
(s) => !existingStepIds.has(s.id) || s.id.startsWith("step-"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Steps to update (exist in both)
|
||||||
|
const stepsToUpdate = input.steps.filter(
|
||||||
|
(s) => existingStepIds.has(s.id) && !s.id.startsWith("step-"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute in transaction
|
||||||
|
await ctx.db.transaction(async (tx) => {
|
||||||
|
// Delete removed steps
|
||||||
|
if (stepsToDelete.length > 0) {
|
||||||
|
await tx.delete(steps).where(
|
||||||
|
inArray(
|
||||||
|
steps.id,
|
||||||
|
stepsToDelete.map((s) => s.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert new steps
|
||||||
|
for (const step of stepsToInsert) {
|
||||||
|
const stepId = step.id.startsWith("step-") ? randomUUID() : step.id;
|
||||||
|
|
||||||
|
await tx.insert(steps).values({
|
||||||
|
id: stepId,
|
||||||
|
experimentId: input.experimentId,
|
||||||
|
name: step.name,
|
||||||
|
description: step.description,
|
||||||
|
type: step.type,
|
||||||
|
orderIndex: step.order,
|
||||||
|
durationEstimate: step.duration,
|
||||||
|
conditions: step.parameters,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing steps
|
||||||
|
for (const step of stepsToUpdate) {
|
||||||
|
await tx
|
||||||
|
.update(steps)
|
||||||
|
.set({
|
||||||
|
name: step.name,
|
||||||
|
description: step.description,
|
||||||
|
type: step.type,
|
||||||
|
orderIndex: step.order,
|
||||||
|
durationEstimate: step.duration,
|
||||||
|
conditions: step.parameters,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(steps.id, step.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update experiment's updated timestamp
|
||||||
|
await tx
|
||||||
|
.update(experiments)
|
||||||
|
.set({ updatedAt: new Date() })
|
||||||
|
.where(eq(experiments.id, input.experimentId));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -788,7 +788,12 @@ export const auditLogs = createTable(
|
|||||||
export const usersRelations = relations(users, ({ many }) => ({
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
accounts: many(accounts),
|
accounts: many(accounts),
|
||||||
sessions: many(sessions),
|
sessions: many(sessions),
|
||||||
systemRoles: many(userSystemRoles),
|
systemRoles: many(userSystemRoles, {
|
||||||
|
relationName: "user",
|
||||||
|
}),
|
||||||
|
grantedRoles: many(userSystemRoles, {
|
||||||
|
relationName: "grantedByUser",
|
||||||
|
}),
|
||||||
createdStudies: many(studies),
|
createdStudies: many(studies),
|
||||||
studyMemberships: many(studyMembers),
|
studyMemberships: many(studyMembers),
|
||||||
createdExperiments: many(experiments),
|
createdExperiments: many(experiments),
|
||||||
@@ -814,10 +819,12 @@ export const userSystemRolesRelations = relations(
|
|||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [userSystemRoles.userId],
|
fields: [userSystemRoles.userId],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "user",
|
||||||
}),
|
}),
|
||||||
grantedByUser: one(users, {
|
grantedByUser: one(users, {
|
||||||
fields: [userSystemRoles.grantedBy],
|
fields: [userSystemRoles.grantedBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "grantedByUser",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user