mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
532 lines
17 KiB
TypeScript
Executable File
532 lines
17 KiB
TypeScript
Executable File
"use client";
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { TestTube } from "lucide-react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useEffect, useState } from "react";
|
|
import { useForm } from "react-hook-form";
|
|
import { z } from "zod";
|
|
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
|
import {
|
|
EntityForm,
|
|
FormField,
|
|
FormSection,
|
|
Tips,
|
|
} from "~/components/ui/entity-form";
|
|
import { Input } from "~/components/ui/input";
|
|
import { Label } from "~/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "~/components/ui/select";
|
|
import { Textarea } from "~/components/ui/textarea";
|
|
import { useStudyContext } from "~/lib/study-context";
|
|
import { api } from "~/trpc/react";
|
|
|
|
import { Calendar as CalendarIcon, Clock } from "lucide-react";
|
|
import { format } from "date-fns";
|
|
import { cn } from "~/lib/utils";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Calendar } from "~/components/ui/calendar";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "~/components/ui/popover";
|
|
import { Controller } from "react-hook-form";
|
|
|
|
// Custom DatePickerTime component based on user request
|
|
function DateTimePicker({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: Date | undefined;
|
|
onChange: (date: Date | undefined) => void;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
// Parse time from value or default
|
|
const timeValue = value ? format(value, "HH:mm") : "12:00";
|
|
|
|
const onDateSelect = (newDate: Date | undefined) => {
|
|
if (!newDate) {
|
|
onChange(undefined);
|
|
setOpen(false);
|
|
return;
|
|
}
|
|
|
|
// Preserve existing time or use default
|
|
const [hours, minutes] = timeValue.split(":").map(Number);
|
|
const updatedDate = new Date(newDate);
|
|
updatedDate.setHours(hours || 0);
|
|
updatedDate.setMinutes(minutes || 0);
|
|
updatedDate.setSeconds(0);
|
|
|
|
onChange(updatedDate);
|
|
setOpen(false);
|
|
};
|
|
|
|
const onTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const newTime = e.target.value;
|
|
if (!value) return; // Can't set time without date
|
|
|
|
const [hours, minutes] = newTime.split(":").map(Number);
|
|
const updatedDate = new Date(value);
|
|
updatedDate.setHours(hours || 0);
|
|
updatedDate.setMinutes(minutes || 0);
|
|
updatedDate.setSeconds(0);
|
|
|
|
onChange(updatedDate);
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-end gap-2">
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="date-picker" className="text-xs">
|
|
Date
|
|
</Label>
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant={"outline"}
|
|
id="date-picker"
|
|
className={cn(
|
|
"w-[240px] justify-start text-left font-normal",
|
|
!value && "text-muted-foreground",
|
|
)}
|
|
>
|
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
{value ? format(value, "PPP") : <span>Pick a date</span>}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-0" align="start">
|
|
<Calendar
|
|
mode="single"
|
|
selected={value}
|
|
onSelect={onDateSelect}
|
|
initialFocus
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="time-picker" className="text-xs">
|
|
Time
|
|
</Label>
|
|
<div className="relative">
|
|
<Input
|
|
id="time-picker"
|
|
type="time"
|
|
value={timeValue}
|
|
onChange={onTimeChange}
|
|
disabled={!value}
|
|
className="w-[120px]"
|
|
/>
|
|
<Clock className="text-muted-foreground pointer-events-none absolute top-2.5 right-3 h-4 w-4" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const trialSchema = z.object({
|
|
experimentId: z.string().uuid("Please select an experiment"),
|
|
participantId: z.string().uuid("Please select a participant"),
|
|
scheduledAt: z.date(),
|
|
wizardId: z.string().uuid().optional(),
|
|
notes: z.string().max(1000, "Notes cannot exceed 1000 characters").optional(),
|
|
sessionNumber: z
|
|
.number()
|
|
.min(1, "Session number must be at least 1")
|
|
.optional(),
|
|
});
|
|
|
|
type TrialFormData = z.infer<typeof trialSchema>;
|
|
|
|
interface TrialFormProps {
|
|
mode: "create" | "edit";
|
|
trialId?: string;
|
|
studyId?: string;
|
|
}
|
|
|
|
export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|
const router = useRouter();
|
|
const { selectedStudyId } = useStudyContext();
|
|
const contextStudyId = studyId ?? selectedStudyId;
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const form = useForm<TrialFormData>({
|
|
resolver: zodResolver(trialSchema),
|
|
defaultValues: {
|
|
sessionNumber: 1,
|
|
},
|
|
});
|
|
|
|
// Fetch trial data for edit mode
|
|
const {
|
|
data: trial,
|
|
isLoading,
|
|
error: fetchError,
|
|
} = api.trials.get.useQuery(
|
|
{ id: trialId! },
|
|
{ enabled: mode === "edit" && !!trialId },
|
|
);
|
|
|
|
// Fetch experiments for the selected study
|
|
const { data: experimentsData, isLoading: experimentsLoading } =
|
|
api.experiments.list.useQuery(
|
|
{ studyId: contextStudyId! },
|
|
{ enabled: !!contextStudyId },
|
|
);
|
|
|
|
// Fetch participants for the selected study
|
|
const { data: participantsData, isLoading: participantsLoading } =
|
|
api.participants.list.useQuery(
|
|
{ studyId: contextStudyId!, limit: 100 },
|
|
{ enabled: !!contextStudyId },
|
|
);
|
|
|
|
// Fetch users who can be wizards
|
|
const { data: usersData, isLoading: usersLoading } =
|
|
api.users.getWizards.useQuery();
|
|
|
|
// Auto-increment session number
|
|
const selectedParticipantId = form.watch("participantId");
|
|
const { data: latestSession } = api.trials.getLatestSession.useQuery(
|
|
{ participantId: selectedParticipantId },
|
|
{
|
|
enabled: !!selectedParticipantId && mode === "create",
|
|
refetchOnWindowFocus: false,
|
|
},
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (latestSession !== undefined && mode === "create") {
|
|
form.setValue("sessionNumber", latestSession + 1);
|
|
}
|
|
}, [latestSession, mode, form]);
|
|
|
|
// Set breadcrumbs
|
|
const breadcrumbs = [
|
|
{ label: "Dashboard", href: "/dashboard" },
|
|
{ label: "Studies", href: "/studies" },
|
|
...(contextStudyId
|
|
? [
|
|
{
|
|
label: "Study",
|
|
href: `/studies/${contextStudyId}`,
|
|
},
|
|
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
|
|
...(mode === "edit" && trial
|
|
? [
|
|
{
|
|
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
|
|
href: `/studies/${contextStudyId}/trials/${trial.id}`,
|
|
},
|
|
{ label: "Edit" },
|
|
]
|
|
: [{ label: "New Trial" }]),
|
|
]
|
|
: [
|
|
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
|
|
...(mode === "edit" && trial
|
|
? [
|
|
{
|
|
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
|
|
href: `/studies/${contextStudyId}/trials/${trial.id}`,
|
|
},
|
|
{ label: "Edit" },
|
|
]
|
|
: [{ label: "New Trial" }]),
|
|
]),
|
|
];
|
|
|
|
useBreadcrumbsEffect(breadcrumbs);
|
|
|
|
// Populate form with existing data in edit mode
|
|
useEffect(() => {
|
|
if (mode === "edit" && trial) {
|
|
form.reset({
|
|
experimentId: trial.experimentId,
|
|
participantId: trial?.participantId ?? "",
|
|
scheduledAt: trial.scheduledAt
|
|
? new Date(trial.scheduledAt)
|
|
: undefined,
|
|
wizardId: trial.wizardId ?? undefined,
|
|
notes: trial.notes ?? "",
|
|
sessionNumber: trial.sessionNumber ?? 1,
|
|
});
|
|
}
|
|
}, [trial, mode, form]);
|
|
|
|
const createTrialMutation = api.trials.create.useMutation();
|
|
const updateTrialMutation = api.trials.update.useMutation();
|
|
|
|
// Form submission
|
|
const onSubmit = async (data: TrialFormData) => {
|
|
setIsSubmitting(true);
|
|
setError(null);
|
|
|
|
try {
|
|
if (mode === "create") {
|
|
await createTrialMutation.mutateAsync({
|
|
experimentId: data.experimentId,
|
|
participantId: data.participantId,
|
|
scheduledAt: data.scheduledAt,
|
|
wizardId: data.wizardId,
|
|
sessionNumber: data.sessionNumber ?? 1,
|
|
notes: data.notes ?? undefined,
|
|
});
|
|
// Redirect to trials table instead of detail page
|
|
router.push(`/studies/${contextStudyId}/trials`);
|
|
} else {
|
|
await updateTrialMutation.mutateAsync({
|
|
id: trialId!,
|
|
scheduledAt: data.scheduledAt,
|
|
wizardId: data.wizardId,
|
|
sessionNumber: data.sessionNumber ?? 1,
|
|
notes: data.notes ?? undefined,
|
|
});
|
|
// Redirect to trials table on update too
|
|
router.push(`/studies/${contextStudyId}/trials`);
|
|
}
|
|
} catch (error) {
|
|
setError(
|
|
`Failed to ${mode} trial: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// Loading state for edit mode
|
|
if (mode === "edit" && isLoading) {
|
|
return <div>Loading trial...</div>;
|
|
}
|
|
|
|
// Error state for edit mode
|
|
if (mode === "edit" && fetchError) {
|
|
return <div>Error loading trial: {fetchError.message}</div>;
|
|
}
|
|
|
|
return (
|
|
<EntityForm
|
|
mode={mode}
|
|
entityName="Trial"
|
|
entityNamePlural="Trials"
|
|
backUrl={`/studies/${contextStudyId}/trials`}
|
|
listUrl={`/studies/${contextStudyId}/trials`}
|
|
title={
|
|
mode === "create"
|
|
? "Schedule New Trial"
|
|
: `Edit ${trial ? `Trial ${trial.sessionNumber || trial.id.slice(-8)}` : "Trial"}`
|
|
}
|
|
description={
|
|
mode === "create"
|
|
? "Schedule a new experimental trial with a participant"
|
|
: "Update trial scheduling and assignment details"
|
|
}
|
|
icon={TestTube}
|
|
form={form}
|
|
onSubmit={onSubmit}
|
|
isSubmitting={isSubmitting}
|
|
error={error}
|
|
sidebar={undefined}
|
|
submitText={mode === "create" ? "Schedule Trial" : "Save Changes"}
|
|
layout="full-width"
|
|
>
|
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
|
{/* Left Column: Main Info (Spans 2) */}
|
|
<div className="space-y-6 md:col-span-2">
|
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
<FormField>
|
|
<Label htmlFor="experimentId">Experiment *</Label>
|
|
<Select
|
|
value={form.watch("experimentId")}
|
|
onValueChange={(value) => form.setValue("experimentId", value)}
|
|
disabled={experimentsLoading || mode === "edit"}
|
|
>
|
|
<SelectTrigger
|
|
className={
|
|
form.formState.errors.experimentId ? "border-red-500" : ""
|
|
}
|
|
>
|
|
<SelectValue
|
|
placeholder={
|
|
experimentsLoading
|
|
? "Loading experiments..."
|
|
: "Select an experiment"
|
|
}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{experimentsData?.map((experiment) => (
|
|
<SelectItem key={experiment.id} value={experiment.id}>
|
|
{experiment.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{form.formState.errors.experimentId && (
|
|
<p className="text-sm text-red-600">
|
|
{form.formState.errors.experimentId.message}
|
|
</p>
|
|
)}
|
|
{mode === "edit" && (
|
|
<p className="text-muted-foreground text-xs">
|
|
Experiment cannot be changed after creation
|
|
</p>
|
|
)}
|
|
</FormField>
|
|
|
|
<FormField>
|
|
<Label htmlFor="participantId">Participant *</Label>
|
|
<Select
|
|
value={form.watch("participantId")}
|
|
onValueChange={(value) => form.setValue("participantId", value)}
|
|
disabled={participantsLoading || mode === "edit"}
|
|
>
|
|
<SelectTrigger
|
|
className={
|
|
form.formState.errors.participantId ? "border-red-500" : ""
|
|
}
|
|
>
|
|
<SelectValue
|
|
placeholder={
|
|
participantsLoading
|
|
? "Loading participants..."
|
|
: "Select a participant"
|
|
}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{participantsData?.participants?.map((participant) => (
|
|
<SelectItem key={participant.id} value={participant.id}>
|
|
{participant.name ?? participant.participantCode} (
|
|
{participant.participantCode})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{form.formState.errors.participantId && (
|
|
<p className="text-sm text-red-600">
|
|
{form.formState.errors.participantId.message}
|
|
</p>
|
|
)}
|
|
{mode === "edit" && (
|
|
<p className="text-muted-foreground text-xs">
|
|
Participant cannot be changed after creation
|
|
</p>
|
|
)}
|
|
</FormField>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
<FormField>
|
|
<Label htmlFor="scheduledAt">Scheduled Date & Time *</Label>
|
|
<Controller
|
|
control={form.control}
|
|
name="scheduledAt"
|
|
render={({ field }) => (
|
|
<DateTimePicker
|
|
value={field.value}
|
|
onChange={field.onChange}
|
|
/>
|
|
)}
|
|
/>
|
|
{form.formState.errors.scheduledAt && (
|
|
<p className="text-sm text-red-600">
|
|
{form.formState.errors.scheduledAt.message}
|
|
</p>
|
|
)}
|
|
<p className="text-muted-foreground text-xs">
|
|
When should this trial be conducted?
|
|
</p>
|
|
</FormField>
|
|
|
|
<FormField>
|
|
<Label htmlFor="sessionNumber">Session Number</Label>
|
|
<Input
|
|
id="sessionNumber"
|
|
type="number"
|
|
min="1"
|
|
{...form.register("sessionNumber", { valueAsNumber: true })}
|
|
placeholder="1"
|
|
className={
|
|
form.formState.errors.sessionNumber ? "border-red-500" : ""
|
|
}
|
|
/>
|
|
{form.formState.errors.sessionNumber && (
|
|
<p className="text-sm text-red-600">
|
|
{form.formState.errors.sessionNumber.message}
|
|
</p>
|
|
)}
|
|
<p className="text-muted-foreground text-xs">
|
|
Auto-incremented based on participant history
|
|
</p>
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Column: Assignment & Notes (Spans 1) */}
|
|
<div className="space-y-6">
|
|
<FormField>
|
|
<Label htmlFor="wizardId">Assigned Wizard</Label>
|
|
<Select
|
|
value={form.watch("wizardId") ?? "none"}
|
|
onValueChange={(value) =>
|
|
form.setValue("wizardId", value === "none" ? undefined : value)
|
|
}
|
|
disabled={usersLoading}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue
|
|
placeholder={
|
|
usersLoading
|
|
? "Loading wizards..."
|
|
: "Select a wizard (optional)"
|
|
}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">No wizard assigned</SelectItem>
|
|
{usersData?.map(
|
|
(user: { id: string; name: string; email: string }) => (
|
|
<SelectItem key={user.id} value={user.id}>
|
|
{user.name} ({user.email})
|
|
</SelectItem>
|
|
),
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-muted-foreground text-xs">
|
|
Who will operate the robot?
|
|
</p>
|
|
</FormField>
|
|
|
|
<FormField>
|
|
<Label htmlFor="notes">Notes</Label>
|
|
<Textarea
|
|
id="notes"
|
|
{...form.register("notes")}
|
|
placeholder="Special instructions..."
|
|
rows={5}
|
|
className={form.formState.errors.notes ? "border-red-500" : ""}
|
|
/>
|
|
{form.formState.errors.notes && (
|
|
<p className="text-sm text-red-600">
|
|
{form.formState.errors.notes.message}
|
|
</p>
|
|
)}
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
</EntityForm>
|
|
);
|
|
}
|