mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
Various improvements: study forms, participant management, PDF generator, robot integration
This commit is contained in:
@@ -197,9 +197,9 @@ export function AppSidebar({
|
||||
// Build study work items with proper URLs when study is selected
|
||||
const studyWorkItemsWithUrls = selectedStudyId
|
||||
? studyWorkItems.map((item) => ({
|
||||
...item,
|
||||
url: `/studies/${selectedStudyId}${item.url}`,
|
||||
}))
|
||||
...item,
|
||||
url: `/studies/${selectedStudyId}${item.url}`,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const handleSignOut = async () => {
|
||||
|
||||
@@ -4,12 +4,12 @@ import { useRef, useState } from "react";
|
||||
import SignatureCanvas from "react-signature-canvas";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { PenBox, Eraser, Loader2, CheckCircle } from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
@@ -25,211 +25,250 @@ import TableHeader from "@tiptap/extension-table-header";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
|
||||
interface DigitalSignatureModalProps {
|
||||
studyId: string;
|
||||
participantId: string;
|
||||
participantName?: string | null;
|
||||
participantCode: string;
|
||||
activeForm: { id: string; content: string; version: number };
|
||||
onSuccess: () => void;
|
||||
studyId: string;
|
||||
participantId: string;
|
||||
participantName?: string | null;
|
||||
participantCode: string;
|
||||
activeForm: { id: string; content: string; version: number };
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function DigitalSignatureModal({
|
||||
studyId,
|
||||
participantId,
|
||||
participantName,
|
||||
participantCode,
|
||||
activeForm,
|
||||
onSuccess,
|
||||
studyId,
|
||||
participantId,
|
||||
participantName,
|
||||
participantCode,
|
||||
activeForm,
|
||||
onSuccess,
|
||||
}: DigitalSignatureModalProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const sigCanvas = useRef<any>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const sigCanvas = useRef<any>(null);
|
||||
|
||||
// Mutations
|
||||
const getUploadUrlMutation = api.participants.getConsentUploadUrl.useMutation();
|
||||
const recordConsentMutation = api.participants.recordConsent.useMutation();
|
||||
// Mutations
|
||||
const getUploadUrlMutation =
|
||||
api.participants.getConsentUploadUrl.useMutation();
|
||||
const recordConsentMutation = api.participants.recordConsent.useMutation();
|
||||
|
||||
// Create a preview version of the text
|
||||
let previewMd = activeForm.content;
|
||||
previewMd = previewMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
|
||||
previewMd = previewMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
|
||||
const today = new Date().toLocaleDateString();
|
||||
previewMd = previewMd.replace(/{{DATE}}/g, today);
|
||||
previewMd = previewMd.replace(/{{SIGNATURE_IMAGE}}/g, "_[Signature Here]_");
|
||||
// Create a preview version of the text
|
||||
let previewMd = activeForm.content;
|
||||
previewMd = previewMd.replace(
|
||||
/{{PARTICIPANT_NAME}}/g,
|
||||
participantName ?? "_________________",
|
||||
);
|
||||
previewMd = previewMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
|
||||
const today = new Date().toLocaleDateString();
|
||||
previewMd = previewMd.replace(/{{DATE}}/g, today);
|
||||
previewMd = previewMd.replace(/{{SIGNATURE_IMAGE}}/g, "_[Signature Here]_");
|
||||
|
||||
const previewEditor = useEditor({
|
||||
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
|
||||
content: previewMd,
|
||||
editable: false,
|
||||
immediatelyRender: false,
|
||||
});
|
||||
const previewEditor = useEditor({
|
||||
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
|
||||
content: previewMd,
|
||||
editable: false,
|
||||
immediatelyRender: false,
|
||||
});
|
||||
|
||||
const handleClear = () => {
|
||||
sigCanvas.current?.clear();
|
||||
};
|
||||
const handleClear = () => {
|
||||
sigCanvas.current?.clear();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (sigCanvas.current?.isEmpty()) {
|
||||
toast.error("Signature required", { description: "Please sign the document before submitting." });
|
||||
return;
|
||||
}
|
||||
const handleSubmit = async () => {
|
||||
if (sigCanvas.current?.isEmpty()) {
|
||||
toast.error("Signature required", {
|
||||
description: "Please sign the document before submitting.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
toast.loading("Generating Signed Document...", { id: "sig-upload" });
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
toast.loading("Generating Signed Document...", { id: "sig-upload" });
|
||||
|
||||
// 1. Get Signature Image Data URL
|
||||
const signatureDataUrl = sigCanvas.current.getTrimmedCanvas().toDataURL("image/png");
|
||||
// 1. Get Signature Image Data URL
|
||||
const signatureDataUrl = sigCanvas.current
|
||||
.getTrimmedCanvas()
|
||||
.toDataURL("image/png");
|
||||
|
||||
// 2. Prepare final Markdown and HTML
|
||||
let finalMd = activeForm.content;
|
||||
finalMd = finalMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
|
||||
finalMd = finalMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
|
||||
finalMd = finalMd.replace(/{{DATE}}/g, today);
|
||||
finalMd = finalMd.replace(/{{SIGNATURE_IMAGE}}/g, `<img src="${signatureDataUrl}" style="height: 60px; max-width: 250px;" />`);
|
||||
// 2. Prepare final Markdown and HTML
|
||||
let finalMd = activeForm.content;
|
||||
finalMd = finalMd.replace(
|
||||
/{{PARTICIPANT_NAME}}/g,
|
||||
participantName ?? "_________________",
|
||||
);
|
||||
finalMd = finalMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
|
||||
finalMd = finalMd.replace(/{{DATE}}/g, today);
|
||||
finalMd = finalMd.replace(
|
||||
/{{SIGNATURE_IMAGE}}/g,
|
||||
`<img src="${signatureDataUrl}" style="height: 60px; max-width: 250px;" />`,
|
||||
);
|
||||
|
||||
const headlessEditor = new Editor({
|
||||
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
|
||||
content: finalMd,
|
||||
});
|
||||
const htmlContent = headlessEditor.getHTML();
|
||||
headlessEditor.destroy();
|
||||
const headlessEditor = new Editor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Table,
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
Markdown,
|
||||
],
|
||||
content: finalMd,
|
||||
});
|
||||
const htmlContent = headlessEditor.getHTML();
|
||||
headlessEditor.destroy();
|
||||
|
||||
// 3. Generate PDF Blob
|
||||
const filename = `Signed_Consent_${participantCode}_v${activeForm.version}.pdf`;
|
||||
const pdfBlob = await generatePdfBlobFromHtml(htmlContent, { filename });
|
||||
const file = new File([pdfBlob], filename, { type: "application/pdf" });
|
||||
// 3. Generate PDF Blob
|
||||
const filename = `Signed_Consent_${participantCode}_v${activeForm.version}.pdf`;
|
||||
const pdfBlob = await generatePdfBlobFromHtml(htmlContent, { filename });
|
||||
const file = new File([pdfBlob], filename, { type: "application/pdf" });
|
||||
|
||||
// 4. Get Presigned URL
|
||||
toast.loading("Uploading Document...", { id: "sig-upload" });
|
||||
const { url, key } = await getUploadUrlMutation.mutateAsync({
|
||||
studyId,
|
||||
participantId,
|
||||
filename: file.name,
|
||||
contentType: file.type,
|
||||
size: file.size,
|
||||
});
|
||||
// 4. Get Presigned URL
|
||||
toast.loading("Uploading Document...", { id: "sig-upload" });
|
||||
const { url, key } = await getUploadUrlMutation.mutateAsync({
|
||||
studyId,
|
||||
participantId,
|
||||
filename: file.name,
|
||||
contentType: file.type,
|
||||
size: file.size,
|
||||
});
|
||||
|
||||
// 5. Upload to MinIO
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("PUT", url, true);
|
||||
xhr.setRequestHeader("Content-Type", file.type);
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("Network error during upload"));
|
||||
xhr.send(file);
|
||||
});
|
||||
// 5. Upload to MinIO
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("PUT", url, true);
|
||||
xhr.setRequestHeader("Content-Type", file.type);
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("Network error during upload"));
|
||||
xhr.send(file);
|
||||
});
|
||||
|
||||
// 6. Record Consent in DB
|
||||
toast.loading("Finalizing Consent...", { id: "sig-upload" });
|
||||
await recordConsentMutation.mutateAsync({
|
||||
participantId,
|
||||
consentFormId: activeForm.id,
|
||||
storagePath: key,
|
||||
});
|
||||
// 6. Record Consent in DB
|
||||
toast.loading("Finalizing Consent...", { id: "sig-upload" });
|
||||
await recordConsentMutation.mutateAsync({
|
||||
participantId,
|
||||
consentFormId: activeForm.id,
|
||||
storagePath: key,
|
||||
});
|
||||
|
||||
toast.success("Consent Successfully Recorded!", { id: "sig-upload" });
|
||||
setIsOpen(false);
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to submit digital signature", {
|
||||
id: "sig-upload",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
toast.success("Consent Successfully Recorded!", { id: "sig-upload" });
|
||||
setIsOpen(false);
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to submit digital signature", {
|
||||
id: "sig-upload",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="default" size="sm" className="bg-primary/90 hover:bg-primary">
|
||||
<PenBox className="mr-2 h-4 w-4" />
|
||||
Sign Digitally
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Digital Consent Signature</DialogTitle>
|
||||
<DialogDescription>
|
||||
Please review the document below and provide your digital signature to consent to this study.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-primary/90 hover:bg-primary"
|
||||
>
|
||||
<PenBox className="mr-2 h-4 w-4" />
|
||||
Sign Digitally
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="flex h-[90vh] max-w-4xl flex-col p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Digital Consent Signature</DialogTitle>
|
||||
<DialogDescription>
|
||||
Please review the document below and provide your digital signature
|
||||
to consent to this study.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
||||
{/* Document Preview (Left) */}
|
||||
<div className="flex flex-col border rounded-md overflow-hidden bg-muted/20">
|
||||
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Document Preview
|
||||
</div>
|
||||
<ScrollArea className="flex-1 w-full bg-white p-6 shadow-inner">
|
||||
<div className="prose prose-sm max-w-none text-black">
|
||||
<EditorContent editor={previewEditor} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="mt-4 grid min-h-0 flex-1 grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* Document Preview (Left) */}
|
||||
<div className="bg-muted/20 flex flex-col overflow-hidden rounded-md border">
|
||||
<div className="bg-muted text-muted-foreground border-b px-4 py-2 text-xs font-semibold tracking-wider uppercase">
|
||||
Document Preview
|
||||
</div>
|
||||
<ScrollArea className="w-full flex-1 bg-white p-6 shadow-inner">
|
||||
<div className="prose prose-sm max-w-none text-black">
|
||||
<EditorContent editor={previewEditor} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Signature Panel (Right) */}
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border rounded-md overflow-hidden bg-white shadow-sm flex flex-col">
|
||||
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Digital Signature Pad
|
||||
</div>
|
||||
<div className="p-4 bg-muted/10 relative">
|
||||
<div className="absolute top-4 right-4">
|
||||
<Button variant="ghost" size="sm" onClick={handleClear} disabled={isSubmitting}>
|
||||
<Eraser className="h-4 w-4 mr-2" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border-2 border-dashed border-input rounded-md bg-white mt-10" style={{ height: "250px" }}>
|
||||
<SignatureCanvas
|
||||
ref={sigCanvas}
|
||||
penColor="black"
|
||||
canvasProps={{ className: "w-full h-full cursor-crosshair rounded-md" }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground mt-2">
|
||||
Draw your signature using your mouse or touch screen inside the box above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Submission Actions */}
|
||||
<div className="flex flex-col space-y-3 p-4 bg-primary/5 rounded-lg border border-primary/20">
|
||||
<h4 className="flex items-center text-sm font-semibold text-primary">
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Agreement
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
By clicking "Submit Signed Document", you confirm that you have read and understood the information provided in the document preview, and you voluntarily agree to participate in this study.
|
||||
</p>
|
||||
<Button
|
||||
className="w-full mt-2"
|
||||
size="lg"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Submit Signed Document"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Signature Panel (Right) */}
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex flex-col overflow-hidden rounded-md border bg-white shadow-sm">
|
||||
<div className="bg-muted text-muted-foreground border-b px-4 py-2 text-xs font-semibold tracking-wider uppercase">
|
||||
Digital Signature Pad
|
||||
</div>
|
||||
<div className="bg-muted/10 relative p-4">
|
||||
<div className="absolute top-4 right-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Eraser className="mr-2 h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
<div
|
||||
className="border-input mt-10 rounded-md border-2 border-dashed bg-white"
|
||||
style={{ height: "250px" }}
|
||||
>
|
||||
<SignatureCanvas
|
||||
ref={sigCanvas}
|
||||
penColor="black"
|
||||
canvasProps={{
|
||||
className: "w-full h-full cursor-crosshair rounded-md",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2 text-center text-xs">
|
||||
Draw your signature using your mouse or touch screen inside
|
||||
the box above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Submission Actions */}
|
||||
<div className="bg-primary/5 border-primary/20 flex flex-col space-y-3 rounded-lg border p-4">
|
||||
<h4 className="text-primary flex items-center text-sm font-semibold">
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Agreement
|
||||
</h4>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
By clicking "Submit Signed Document", you confirm that you have
|
||||
read and understood the information provided in the document
|
||||
preview, and you voluntarily agree to participate in this study.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-2 w-full"
|
||||
size="lg"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Submit Signed Document"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,10 @@ export function ParticipantConsentManager({
|
||||
existingConsent,
|
||||
participantName,
|
||||
participantCode,
|
||||
}: ParticipantConsentManagerProps & { participantName?: string | null; participantCode: string }) {
|
||||
}: ParticipantConsentManagerProps & {
|
||||
participantName?: string | null;
|
||||
participantCode: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
|
||||
@@ -99,14 +102,24 @@ export function ParticipantConsentManager({
|
||||
|
||||
// Substitute placeholders in markdown
|
||||
let customMd = activeForm.content;
|
||||
customMd = customMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
|
||||
customMd = customMd.replace(
|
||||
/{{PARTICIPANT_NAME}}/g,
|
||||
participantName ?? "_________________",
|
||||
);
|
||||
customMd = customMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
|
||||
customMd = customMd.replace(/{{DATE}}/g, "_________________");
|
||||
customMd = customMd.replace(/{{SIGNATURE_IMAGE}}/g, ""); // Blank ready for physical signature
|
||||
|
||||
// Use headless Tiptap to parse MD to HTML via same extensions
|
||||
const editor = new Editor({
|
||||
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Table,
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
Markdown,
|
||||
],
|
||||
content: customMd,
|
||||
});
|
||||
|
||||
@@ -195,7 +208,11 @@ export function ParticipantConsentManager({
|
||||
activeForm={activeForm}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleDownloadUnsigned}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadUnsigned}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Print Empty Form
|
||||
</Button>
|
||||
|
||||
@@ -119,39 +119,39 @@ export function ParticipantForm({
|
||||
{ label: "Studies", href: "/studies" },
|
||||
...(contextStudyId
|
||||
? [
|
||||
{
|
||||
label: participant?.study?.name ?? "Study",
|
||||
href: `/studies/${contextStudyId}`,
|
||||
},
|
||||
{
|
||||
label: "Participants",
|
||||
href: `/studies/${contextStudyId}/participants`,
|
||||
},
|
||||
...(mode === "edit" && participant
|
||||
? [
|
||||
{
|
||||
label: participant.name ?? participant.participantCode,
|
||||
href: `/studies/${contextStudyId}/participants/${participant.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Participant" }]),
|
||||
]
|
||||
{
|
||||
label: participant?.study?.name ?? "Study",
|
||||
href: `/studies/${contextStudyId}`,
|
||||
},
|
||||
{
|
||||
label: "Participants",
|
||||
href: `/studies/${contextStudyId}/participants`,
|
||||
},
|
||||
...(mode === "edit" && participant
|
||||
? [
|
||||
{
|
||||
label: participant.name ?? participant.participantCode,
|
||||
href: `/studies/${contextStudyId}/participants/${participant.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Participant" }]),
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: "Participants",
|
||||
href: `/studies/${contextStudyId}/participants`,
|
||||
},
|
||||
...(mode === "edit" && participant
|
||||
? [
|
||||
{
|
||||
label: participant.name ?? participant.participantCode,
|
||||
href: `/studies/${contextStudyId}/participants/${participant.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Participant" }]),
|
||||
]),
|
||||
{
|
||||
label: "Participants",
|
||||
href: `/studies/${contextStudyId}/participants`,
|
||||
},
|
||||
...(mode === "edit" && participant
|
||||
? [
|
||||
{
|
||||
label: participant.name ?? participant.participantCode,
|
||||
href: `/studies/${contextStudyId}/participants/${participant.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Participant" }]),
|
||||
]),
|
||||
];
|
||||
|
||||
useBreadcrumbsEffect(breadcrumbs);
|
||||
@@ -291,7 +291,7 @@ export function ParticipantForm({
|
||||
readOnly={true}
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground",
|
||||
form.formState.errors.participantCode ? "border-red-500" : ""
|
||||
form.formState.errors.participantCode ? "border-red-500" : "",
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.participantCode && (
|
||||
@@ -338,7 +338,11 @@ export function ParticipantForm({
|
||||
|
||||
<FormSection
|
||||
title={contextStudyId ? "Demographics" : "Demographics & Study"}
|
||||
description={contextStudyId ? "Participant demographic details." : "Study association and demographic details."}
|
||||
description={
|
||||
contextStudyId
|
||||
? "Participant demographic details."
|
||||
: "Study association and demographic details."
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{!contextStudyId && (
|
||||
@@ -358,7 +362,9 @@ export function ParticipantForm({
|
||||
}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={studiesLoading ? "Loading..." : "Select study"}
|
||||
placeholder={
|
||||
studiesLoading ? "Loading..." : "Select study"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -404,11 +410,11 @@ export function ParticipantForm({
|
||||
form.setValue(
|
||||
"gender",
|
||||
value as
|
||||
| "male"
|
||||
| "female"
|
||||
| "non_binary"
|
||||
| "prefer_not_to_say"
|
||||
| "other",
|
||||
| "male"
|
||||
| "female"
|
||||
| "non_binary"
|
||||
| "prefer_not_to_say"
|
||||
| "other",
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -187,7 +187,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
},
|
||||
});
|
||||
|
||||
const executeSystemActionMutation = api.robots.executeSystemAction.useMutation();
|
||||
const executeSystemActionMutation =
|
||||
api.robots.executeSystemAction.useMutation();
|
||||
const [isCompleting, setIsCompleting] = useState(false);
|
||||
|
||||
// Map database step types to component step types
|
||||
@@ -632,17 +633,19 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
|
||||
if (matchedOption) {
|
||||
// Handle both string options and object options for nextStepId
|
||||
const nextStepId = typeof matchedOption === "string"
|
||||
? null // String options don't have nextStepId
|
||||
: matchedOption.nextStepId;
|
||||
const nextStepId =
|
||||
typeof matchedOption === "string"
|
||||
? null // String options don't have nextStepId
|
||||
: matchedOption.nextStepId;
|
||||
|
||||
if (nextStepId) {
|
||||
// Find index of the target step
|
||||
const targetIndex = steps.findIndex((s) => s.id === nextStepId);
|
||||
if (targetIndex !== -1) {
|
||||
const label = typeof matchedOption === "string"
|
||||
? matchedOption
|
||||
: matchedOption.label;
|
||||
const label =
|
||||
typeof matchedOption === "string"
|
||||
? matchedOption
|
||||
: matchedOption.label;
|
||||
|
||||
console.log(
|
||||
`[WizardInterface] Branching to step ${targetIndex} (${label})`,
|
||||
|
||||
Reference in New Issue
Block a user