mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-05-08 05:48:56 -04:00
feat: add initial seed data migration and form builder components
- Created migration 0001_seed_data.sql to insert minimal seed data for users, accounts, and roles. - Added meta journal for migration tracking. - Implemented FormBuilder component for dynamic form field creation and management. - Developed FormFieldRenderer component to render various types of form fields based on user input. - Introduced constants for trust levels and status configurations. - Defined types for form fields and trial data structures to enhance type safety and clarity.
This commit is contained in:
@@ -9,4 +9,5 @@ export default {
|
||||
url: env.DATABASE_URL,
|
||||
},
|
||||
tablesFilter: ["hs_*"],
|
||||
out: "./migrations",
|
||||
} satisfies Config;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1774137504617,
|
||||
"tag": "0000_old_tattoo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
CREATE TYPE "public"."block_category" AS ENUM('wizard', 'robot', 'control', 'sensor', 'logic', 'event');--> statement-breakpoint
|
||||
CREATE TYPE "public"."block_shape" AS ENUM('action', 'control', 'value', 'boolean', 'hat', 'cap');--> statement-breakpoint
|
||||
CREATE TYPE "public"."communication_protocol" AS ENUM('rest', 'ros2', 'custom');--> statement-breakpoint
|
||||
CREATE TYPE "public"."experiment_status" AS ENUM('draft', 'testing', 'ready', 'deprecated');--> statement-breakpoint
|
||||
CREATE TYPE "public"."export_status" AS ENUM('pending', 'processing', 'completed', 'failed');--> statement-breakpoint
|
||||
CREATE TYPE "public"."form_field_type" AS ENUM('text', 'textarea', 'multiple_choice', 'checkbox', 'rating', 'yes_no', 'date', 'signature');--> statement-breakpoint
|
||||
CREATE TYPE "public"."form_response_status" AS ENUM('pending', 'completed', 'rejected');--> statement-breakpoint
|
||||
CREATE TYPE "public"."form_type" AS ENUM('consent', 'survey', 'questionnaire');--> statement-breakpoint
|
||||
CREATE TYPE "public"."media_type" AS ENUM('video', 'audio', 'image');--> statement-breakpoint
|
||||
CREATE TYPE "public"."plugin_status" AS ENUM('active', 'deprecated', 'disabled');--> statement-breakpoint
|
||||
CREATE TYPE "public"."step_type" AS ENUM('wizard', 'robot', 'parallel', 'conditional');--> statement-breakpoint
|
||||
CREATE TYPE "public"."study_member_role" AS ENUM('owner', 'researcher', 'wizard', 'observer');--> statement-breakpoint
|
||||
CREATE TYPE "public"."study_status" AS ENUM('draft', 'active', 'completed', 'archived');--> statement-breakpoint
|
||||
CREATE TYPE "public"."system_role" AS ENUM('administrator', 'researcher', 'wizard', 'observer');--> statement-breakpoint
|
||||
CREATE TYPE "public"."trial_status" AS ENUM('scheduled', 'in_progress', 'completed', 'aborted', 'failed');--> statement-breakpoint
|
||||
CREATE TYPE "public"."trust_level" AS ENUM('official', 'verified', 'community');--> statement-breakpoint
|
||||
CREATE TABLE "hs_account" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"provider_id" varchar(255) NOT NULL,
|
||||
"account_id" varchar(255) NOT NULL,
|
||||
"refresh_token" text,
|
||||
"access_token" text,
|
||||
"expires_at" timestamp with time zone,
|
||||
"scope" varchar(255),
|
||||
"password" text,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
CONSTRAINT "hs_account_provider_id_account_id_unique" UNIQUE("provider_id","account_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_action" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"step_id" uuid NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"description" text,
|
||||
"type" varchar(100) NOT NULL,
|
||||
"order_index" integer NOT NULL,
|
||||
"parameters" jsonb DEFAULT '{}'::jsonb,
|
||||
"validation_schema" jsonb,
|
||||
"timeout" integer,
|
||||
"retry_count" integer DEFAULT 0 NOT NULL,
|
||||
"source_kind" varchar(20),
|
||||
"plugin_id" varchar(255),
|
||||
"plugin_version" varchar(50),
|
||||
"robot_id" varchar(255),
|
||||
"base_action_id" varchar(255),
|
||||
"category" varchar(50),
|
||||
"transport" varchar(20),
|
||||
"ros2_config" jsonb,
|
||||
"rest_config" jsonb,
|
||||
"retryable" boolean,
|
||||
"parameter_schema_raw" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
CONSTRAINT "hs_action_step_id_order_index_unique" UNIQUE("step_id","order_index")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_activity_log" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"study_id" uuid,
|
||||
"user_id" text,
|
||||
"action" varchar(100) NOT NULL,
|
||||
"resource_type" varchar(50),
|
||||
"resource_id" uuid,
|
||||
"description" text,
|
||||
"ip_address" "inet",
|
||||
"user_agent" text,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_annotation" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"trial_id" uuid NOT NULL,
|
||||
"annotator_id" text NOT NULL,
|
||||
"timestamp_start" timestamp with time zone NOT NULL,
|
||||
"timestamp_end" timestamp with time zone,
|
||||
"category" varchar(100),
|
||||
"label" varchar(100),
|
||||
"description" text,
|
||||
"tags" jsonb DEFAULT '[]'::jsonb,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_attachment" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"resource_type" varchar(50) NOT NULL,
|
||||
"resource_id" uuid NOT NULL,
|
||||
"file_name" varchar(255) NOT NULL,
|
||||
"file_size" bigint NOT NULL,
|
||||
"file_path" text NOT NULL,
|
||||
"content_type" varchar(100),
|
||||
"description" text,
|
||||
"uploaded_by" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_audit_log" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text,
|
||||
"action" varchar(100) NOT NULL,
|
||||
"resource_type" varchar(50),
|
||||
"resource_id" uuid,
|
||||
"changes" jsonb DEFAULT '{}'::jsonb,
|
||||
"ip_address" "inet",
|
||||
"user_agent" text,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_block_registry" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"block_type" varchar(100) NOT NULL,
|
||||
"plugin_id" uuid,
|
||||
"shape" "block_shape" NOT NULL,
|
||||
"category" "block_category" NOT NULL,
|
||||
"display_name" varchar(255) NOT NULL,
|
||||
"description" text,
|
||||
"icon" varchar(100),
|
||||
"color" varchar(50),
|
||||
"config" jsonb NOT NULL,
|
||||
"parameter_schema" jsonb NOT NULL,
|
||||
"execution_handler" varchar(100),
|
||||
"timeout" integer,
|
||||
"retry_policy" jsonb,
|
||||
"requires_connection" boolean DEFAULT false,
|
||||
"preview_mode" boolean DEFAULT true,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
CONSTRAINT "hs_block_registry_block_type_plugin_id_unique" UNIQUE("block_type","plugin_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_comment" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"parent_id" uuid,
|
||||
"resource_type" varchar(50) NOT NULL,
|
||||
"resource_id" uuid NOT NULL,
|
||||
"author_id" text NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_consent_form" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"study_id" uuid NOT NULL,
|
||||
"version" integer DEFAULT 1 NOT NULL,
|
||||
"title" varchar(255) NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"created_by" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"storage_path" text,
|
||||
CONSTRAINT "hs_consent_form_study_id_version_unique" UNIQUE("study_id","version")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_experiment" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"study_id" uuid NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"description" text,
|
||||
"version" integer DEFAULT 1 NOT NULL,
|
||||
"robot_id" uuid,
|
||||
"status" "experiment_status" DEFAULT 'draft' NOT NULL,
|
||||
"estimated_duration" integer,
|
||||
"created_by" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||
"visual_design" jsonb,
|
||||
"execution_graph" jsonb,
|
||||
"plugin_dependencies" text[],
|
||||
"integrity_hash" varchar(128),
|
||||
"deleted_at" timestamp with time zone,
|
||||
CONSTRAINT "hs_experiment_study_id_name_version_unique" UNIQUE("study_id","name","version")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_export_job" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"study_id" uuid NOT NULL,
|
||||
"requested_by" text NOT NULL,
|
||||
"export_type" varchar(50) NOT NULL,
|
||||
"format" varchar(20) NOT NULL,
|
||||
"filters" jsonb DEFAULT '{}'::jsonb,
|
||||
"status" "export_status" DEFAULT 'pending' NOT NULL,
|
||||
"storage_path" text,
|
||||
"expires_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"completed_at" timestamp with time zone,
|
||||
"error_message" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_form_response" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"form_id" uuid NOT NULL,
|
||||
"participant_id" uuid NOT NULL,
|
||||
"responses" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"status" "form_response_status" DEFAULT 'pending',
|
||||
"signature_data" text,
|
||||
"signed_at" timestamp with time zone,
|
||||
"ip_address" "inet",
|
||||
"submitted_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
CONSTRAINT "hs_form_response_form_id_participant_id_unique" UNIQUE("form_id","participant_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_form" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"study_id" uuid NOT NULL,
|
||||
"type" "form_type" NOT NULL,
|
||||
"title" varchar(255) NOT NULL,
|
||||
"description" text,
|
||||
"version" integer DEFAULT 1 NOT NULL,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"is_template" boolean DEFAULT false NOT NULL,
|
||||
"template_name" varchar(100),
|
||||
"fields" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"settings" jsonb DEFAULT '{}'::jsonb,
|
||||
"created_by" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
CONSTRAINT "hs_form_study_id_version_unique" UNIQUE("study_id","version")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_media_capture" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"trial_id" uuid NOT NULL,
|
||||
"media_type" "media_type",
|
||||
"storage_path" text NOT NULL,
|
||||
"file_size" bigint,
|
||||
"duration" integer,
|
||||
"format" varchar(20),
|
||||
"resolution" varchar(20),
|
||||
"start_timestamp" timestamp with time zone,
|
||||
"end_timestamp" timestamp with time zone,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_participant_consent" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"participant_id" uuid NOT NULL,
|
||||
"consent_form_id" uuid NOT NULL,
|
||||
"signed_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"signature_data" text,
|
||||
"ip_address" "inet",
|
||||
"storage_path" text,
|
||||
CONSTRAINT "hs_participant_consent_participant_id_consent_form_id_unique" UNIQUE("participant_id","consent_form_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_participant_document" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"participant_id" uuid NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"type" varchar(100),
|
||||
"storage_path" text NOT NULL,
|
||||
"file_size" integer,
|
||||
"uploaded_by" text,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_participant" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"study_id" uuid NOT NULL,
|
||||
"participant_code" varchar(50) NOT NULL,
|
||||
"email" varchar(255),
|
||||
"name" varchar(255),
|
||||
"demographics" jsonb DEFAULT '{}'::jsonb,
|
||||
"consent_given" boolean DEFAULT false NOT NULL,
|
||||
"consent_date" timestamp with time zone,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
CONSTRAINT "hs_participant_study_id_participant_code_unique" UNIQUE("study_id","participant_code")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_permission" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" varchar(100) NOT NULL,
|
||||
"description" text,
|
||||
"resource" varchar(50) NOT NULL,
|
||||
"action" varchar(50) NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
CONSTRAINT "hs_permission_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_plugin_repository" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"description" text,
|
||||
"trust_level" "trust_level" DEFAULT 'community' NOT NULL,
|
||||
"is_enabled" boolean DEFAULT true NOT NULL,
|
||||
"is_official" boolean DEFAULT false NOT NULL,
|
||||
"last_sync_at" timestamp with time zone,
|
||||
"sync_status" varchar(50) DEFAULT 'pending',
|
||||
"sync_error" text,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"created_by" text NOT NULL,
|
||||
CONSTRAINT "hs_plugin_repository_url_unique" UNIQUE("url")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_plugin" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"robot_id" uuid,
|
||||
"identifier" varchar(100) NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"version" varchar(50) NOT NULL,
|
||||
"description" text,
|
||||
"author" varchar(255),
|
||||
"repository_url" text,
|
||||
"trust_level" "trust_level",
|
||||
"status" "plugin_status" DEFAULT 'active' NOT NULL,
|
||||
"configuration_schema" jsonb,
|
||||
"action_definitions" jsonb DEFAULT '[]'::jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||
CONSTRAINT "hs_plugin_identifier_unique" UNIQUE("identifier"),
|
||||
CONSTRAINT "hs_plugin_name_version_unique" UNIQUE("name","version")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_robot_plugin" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"version" varchar(50) NOT NULL,
|
||||
"manufacturer" varchar(255),
|
||||
"description" text,
|
||||
"robot_id" uuid,
|
||||
"communication_protocol" "communication_protocol",
|
||||
"status" "plugin_status" DEFAULT 'active' NOT NULL,
|
||||
"config_schema" jsonb,
|
||||
"capabilities" jsonb DEFAULT '[]'::jsonb,
|
||||
"trust_level" "trust_level" DEFAULT 'community' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_robot" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"manufacturer" varchar(255),
|
||||
"model" varchar(255),
|
||||
"description" text,
|
||||
"capabilities" jsonb DEFAULT '[]'::jsonb,
|
||||
"communication_protocol" "communication_protocol",
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_role_permission" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"role" "system_role" NOT NULL,
|
||||
"permission_id" uuid NOT NULL,
|
||||
CONSTRAINT "hs_role_permission_role_permission_id_unique" UNIQUE("role","permission_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_sensor_data" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"trial_id" uuid NOT NULL,
|
||||
"sensor_type" varchar(50) NOT NULL,
|
||||
"timestamp" timestamp with time zone NOT NULL,
|
||||
"data" jsonb NOT NULL,
|
||||
"robot_state" jsonb DEFAULT '{}'::jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_session" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"token" varchar(255) NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
CONSTRAINT "hs_session_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_shared_resource" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"study_id" uuid NOT NULL,
|
||||
"resource_type" varchar(50) NOT NULL,
|
||||
"resource_id" uuid NOT NULL,
|
||||
"shared_by" text NOT NULL,
|
||||
"share_token" varchar(255),
|
||||
"permissions" jsonb DEFAULT '["read"]'::jsonb,
|
||||
"expires_at" timestamp with time zone,
|
||||
"access_count" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
CONSTRAINT "hs_shared_resource_share_token_unique" UNIQUE("share_token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_step" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"experiment_id" uuid NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"description" text,
|
||||
"type" "step_type" NOT NULL,
|
||||
"order_index" integer NOT NULL,
|
||||
"duration_estimate" integer,
|
||||
"required" boolean DEFAULT true NOT NULL,
|
||||
"conditions" jsonb DEFAULT '{}'::jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
CONSTRAINT "hs_step_experiment_id_order_index_unique" UNIQUE("experiment_id","order_index")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_study" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"description" text,
|
||||
"institution" varchar(255),
|
||||
"irb_protocol" varchar(100),
|
||||
"status" "study_status" DEFAULT 'draft' NOT NULL,
|
||||
"created_by" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||
"settings" jsonb DEFAULT '{}'::jsonb,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_study_member" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"study_id" uuid NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"role" "study_member_role" NOT NULL,
|
||||
"permissions" jsonb DEFAULT '[]'::jsonb,
|
||||
"joined_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"invited_by" text,
|
||||
CONSTRAINT "hs_study_member_study_id_user_id_unique" UNIQUE("study_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_study_plugin" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"study_id" uuid NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"configuration" jsonb DEFAULT '{}'::jsonb,
|
||||
"installed_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"installed_by" text NOT NULL,
|
||||
CONSTRAINT "hs_study_plugin_study_id_plugin_id_unique" UNIQUE("study_id","plugin_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_system_setting" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"key" varchar(100) NOT NULL,
|
||||
"value" jsonb NOT NULL,
|
||||
"description" text,
|
||||
"updated_by" text,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
CONSTRAINT "hs_system_setting_key_unique" UNIQUE("key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_trial_event" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"trial_id" uuid NOT NULL,
|
||||
"event_type" varchar(50) NOT NULL,
|
||||
"action_id" uuid,
|
||||
"timestamp" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"data" jsonb DEFAULT '{}'::jsonb,
|
||||
"created_by" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_trial" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"experiment_id" uuid NOT NULL,
|
||||
"participant_id" uuid,
|
||||
"wizard_id" text,
|
||||
"session_number" integer DEFAULT 1 NOT NULL,
|
||||
"status" "trial_status" DEFAULT 'scheduled' NOT NULL,
|
||||
"scheduled_at" timestamp with time zone,
|
||||
"started_at" timestamp with time zone,
|
||||
"completed_at" timestamp with time zone,
|
||||
"duration" integer,
|
||||
"notes" text,
|
||||
"parameters" jsonb DEFAULT '{}'::jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_user_system_role" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"role" "system_role" NOT NULL,
|
||||
"granted_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"granted_by" text,
|
||||
CONSTRAINT "hs_user_system_role_user_id_role_unique" UNIQUE("user_id","role")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_user" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" varchar(255),
|
||||
"email" varchar(255) NOT NULL,
|
||||
"email_verified" boolean DEFAULT false NOT NULL,
|
||||
"image" text,
|
||||
"password" varchar(255),
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"deleted_at" timestamp with time zone,
|
||||
CONSTRAINT "hs_user_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_verification_token" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"identifier" varchar(255) NOT NULL,
|
||||
"value" varchar(255) NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
CONSTRAINT "hs_verification_token_value_unique" UNIQUE("value")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_wizard_intervention" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"trial_id" uuid NOT NULL,
|
||||
"wizard_id" text NOT NULL,
|
||||
"intervention_type" varchar(100) NOT NULL,
|
||||
"description" text,
|
||||
"timestamp" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"parameters" jsonb DEFAULT '{}'::jsonb,
|
||||
"reason" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hs_ws_connection" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"trial_id" uuid NOT NULL,
|
||||
"client_id" text NOT NULL,
|
||||
"user_id" text,
|
||||
"connected_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
CONSTRAINT "hs_ws_connection_client_id_unique" UNIQUE("client_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "hs_account" ADD CONSTRAINT "hs_account_user_id_hs_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."hs_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_action" ADD CONSTRAINT "hs_action_step_id_hs_step_id_fk" FOREIGN KEY ("step_id") REFERENCES "public"."hs_step"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_activity_log" ADD CONSTRAINT "hs_activity_log_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_activity_log" ADD CONSTRAINT "hs_activity_log_user_id_hs_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_annotation" ADD CONSTRAINT "hs_annotation_trial_id_hs_trial_id_fk" FOREIGN KEY ("trial_id") REFERENCES "public"."hs_trial"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_annotation" ADD CONSTRAINT "hs_annotation_annotator_id_hs_user_id_fk" FOREIGN KEY ("annotator_id") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_attachment" ADD CONSTRAINT "hs_attachment_uploaded_by_hs_user_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_audit_log" ADD CONSTRAINT "hs_audit_log_user_id_hs_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_block_registry" ADD CONSTRAINT "hs_block_registry_plugin_id_hs_robot_plugin_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."hs_robot_plugin"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_comment" ADD CONSTRAINT "hs_comment_author_id_hs_user_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_consent_form" ADD CONSTRAINT "hs_consent_form_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_consent_form" ADD CONSTRAINT "hs_consent_form_created_by_hs_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_experiment" ADD CONSTRAINT "hs_experiment_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_experiment" ADD CONSTRAINT "hs_experiment_robot_id_hs_robot_id_fk" FOREIGN KEY ("robot_id") REFERENCES "public"."hs_robot"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_experiment" ADD CONSTRAINT "hs_experiment_created_by_hs_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_export_job" ADD CONSTRAINT "hs_export_job_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_export_job" ADD CONSTRAINT "hs_export_job_requested_by_hs_user_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_form_response" ADD CONSTRAINT "hs_form_response_form_id_hs_form_id_fk" FOREIGN KEY ("form_id") REFERENCES "public"."hs_form"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_form_response" ADD CONSTRAINT "hs_form_response_participant_id_hs_participant_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."hs_participant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_form" ADD CONSTRAINT "hs_form_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_form" ADD CONSTRAINT "hs_form_created_by_hs_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_media_capture" ADD CONSTRAINT "hs_media_capture_trial_id_hs_trial_id_fk" FOREIGN KEY ("trial_id") REFERENCES "public"."hs_trial"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_participant_consent" ADD CONSTRAINT "hs_participant_consent_participant_id_hs_participant_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."hs_participant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_participant_consent" ADD CONSTRAINT "hs_participant_consent_consent_form_id_hs_consent_form_id_fk" FOREIGN KEY ("consent_form_id") REFERENCES "public"."hs_consent_form"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_participant_document" ADD CONSTRAINT "hs_participant_document_participant_id_hs_participant_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."hs_participant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_participant_document" ADD CONSTRAINT "hs_participant_document_uploaded_by_hs_user_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_participant" ADD CONSTRAINT "hs_participant_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_plugin_repository" ADD CONSTRAINT "hs_plugin_repository_created_by_hs_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_plugin" ADD CONSTRAINT "hs_plugin_robot_id_hs_robot_id_fk" FOREIGN KEY ("robot_id") REFERENCES "public"."hs_robot"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_robot_plugin" ADD CONSTRAINT "hs_robot_plugin_robot_id_hs_robot_id_fk" FOREIGN KEY ("robot_id") REFERENCES "public"."hs_robot"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_role_permission" ADD CONSTRAINT "hs_role_permission_permission_id_hs_permission_id_fk" FOREIGN KEY ("permission_id") REFERENCES "public"."hs_permission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_sensor_data" ADD CONSTRAINT "hs_sensor_data_trial_id_hs_trial_id_fk" FOREIGN KEY ("trial_id") REFERENCES "public"."hs_trial"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_session" ADD CONSTRAINT "hs_session_user_id_hs_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."hs_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_shared_resource" ADD CONSTRAINT "hs_shared_resource_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_shared_resource" ADD CONSTRAINT "hs_shared_resource_shared_by_hs_user_id_fk" FOREIGN KEY ("shared_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_step" ADD CONSTRAINT "hs_step_experiment_id_hs_experiment_id_fk" FOREIGN KEY ("experiment_id") REFERENCES "public"."hs_experiment"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_study" ADD CONSTRAINT "hs_study_created_by_hs_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_study_member" ADD CONSTRAINT "hs_study_member_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_study_member" ADD CONSTRAINT "hs_study_member_user_id_hs_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."hs_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_study_member" ADD CONSTRAINT "hs_study_member_invited_by_hs_user_id_fk" FOREIGN KEY ("invited_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_study_plugin" ADD CONSTRAINT "hs_study_plugin_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_study_plugin" ADD CONSTRAINT "hs_study_plugin_plugin_id_hs_plugin_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."hs_plugin"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_study_plugin" ADD CONSTRAINT "hs_study_plugin_installed_by_hs_user_id_fk" FOREIGN KEY ("installed_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_system_setting" ADD CONSTRAINT "hs_system_setting_updated_by_hs_user_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_trial_event" ADD CONSTRAINT "hs_trial_event_trial_id_hs_trial_id_fk" FOREIGN KEY ("trial_id") REFERENCES "public"."hs_trial"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_trial_event" ADD CONSTRAINT "hs_trial_event_action_id_hs_action_id_fk" FOREIGN KEY ("action_id") REFERENCES "public"."hs_action"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_trial_event" ADD CONSTRAINT "hs_trial_event_created_by_hs_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_trial" ADD CONSTRAINT "hs_trial_experiment_id_hs_experiment_id_fk" FOREIGN KEY ("experiment_id") REFERENCES "public"."hs_experiment"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_trial" ADD CONSTRAINT "hs_trial_participant_id_hs_participant_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."hs_participant"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_trial" ADD CONSTRAINT "hs_trial_wizard_id_hs_user_id_fk" FOREIGN KEY ("wizard_id") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_user_system_role" ADD CONSTRAINT "hs_user_system_role_user_id_hs_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."hs_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_user_system_role" ADD CONSTRAINT "hs_user_system_role_granted_by_hs_user_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_wizard_intervention" ADD CONSTRAINT "hs_wizard_intervention_trial_id_hs_trial_id_fk" FOREIGN KEY ("trial_id") REFERENCES "public"."hs_trial"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_wizard_intervention" ADD CONSTRAINT "hs_wizard_intervention_wizard_id_hs_user_id_fk" FOREIGN KEY ("wizard_id") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hs_ws_connection" ADD CONSTRAINT "hs_ws_connection_trial_id_hs_trial_id_fk" FOREIGN KEY ("trial_id") REFERENCES "public"."hs_trial"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "account_user_id_idx" ON "hs_account" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "activity_logs_study_created_idx" ON "hs_activity_log" USING btree ("study_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "audit_logs_created_idx" ON "hs_audit_log" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "block_registry_category_idx" ON "hs_block_registry" USING btree ("category");--> statement-breakpoint
|
||||
CREATE INDEX "experiment_visual_design_idx" ON "hs_experiment" USING gin ("visual_design");--> statement-breakpoint
|
||||
CREATE INDEX "participant_document_participant_idx" ON "hs_participant_document" USING btree ("participant_id");--> statement-breakpoint
|
||||
CREATE INDEX "sensor_data_trial_timestamp_idx" ON "hs_sensor_data" USING btree ("trial_id","timestamp");--> statement-breakpoint
|
||||
CREATE INDEX "session_user_id_idx" ON "hs_session" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "trial_events_trial_timestamp_idx" ON "hs_trial_event" USING btree ("trial_id","timestamp");--> statement-breakpoint
|
||||
CREATE INDEX "verification_token_identifier_idx" ON "hs_verification_token" USING btree ("identifier");--> statement-breakpoint
|
||||
CREATE INDEX "verification_token_value_idx" ON "hs_verification_token" USING btree ("value");
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Migration 0001: Minimal Seed Data
|
||||
-- HRIStudio - Only essential data needed for auth
|
||||
|
||||
-- ======================
|
||||
-- USERS & AUTH
|
||||
-- ======================
|
||||
|
||||
-- Users (using valid UUID v4 format)
|
||||
INSERT INTO "hs_user" ("id", "name", "email", "email_verified", "image", "created_at", "updated_at")
|
||||
VALUES
|
||||
('11111111-1111-4111-8111-111111111111', 'Sean O''Connor', 'sean@soconnor.dev', true, 'https://www.gravatar.com/avatar/4b20f4a15f9a0e0f5e5e5a0f5e5e5a0f?d=identicon', NOW(), NOW()),
|
||||
('22222222-2222-4222-8222-222222222222', 'Dr. Felipe Perrone', 'felipe.perrone@bucknell.edu', true, 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felipe', NOW(), NOW())
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Accounts
|
||||
INSERT INTO "hs_account" ("id", "user_id", "provider_id", "account_id", "password", "created_at", "updated_at")
|
||||
VALUES
|
||||
('aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa', '11111111-1111-4111-8111-111111111111', 'credential', '11111111-1111-4111-8111-111111111111', '$2b$12$50kgpkp.qZrZXCWjHuVSHOZBjAQUrX50VdtWc6WBj27HnzUYFwwPm', NOW(), NOW()),
|
||||
('bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb', '22222222-2222-4222-8222-222222222222', 'credential', '22222222-2222-4222-8222-222222222222', '$2b$12$50kgpkp.qZrZXCWjHuVSHOZBjAQUrX50VdtWc6WBj27HnzUYFwwPm', NOW(), NOW())
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- System Roles
|
||||
INSERT INTO "hs_user_system_role" ("id", "user_id", "role", "granted_at", "granted_by")
|
||||
VALUES
|
||||
(gen_random_uuid(), '11111111-1111-4111-8111-111111111111', 'administrator', NOW(), '11111111-1111-4111-8111-111111111111'),
|
||||
(gen_random_uuid(), '22222222-2222-4222-8222-222222222222', 'researcher', NOW(), '11111111-1111-4111-8111-111111111111')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE 'Minimal seed migration complete';
|
||||
RAISE NOTICE 'Admin: sean@soconnor.dev / password123';
|
||||
RAISE NOTICE 'Use bun db:seed for full demo data';
|
||||
END $$;
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 0,
|
||||
"tag": "0000_init_schema",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1,
|
||||
"tag": "0001_seed_data",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-1
Submodule robot-plugins updated: f83a207b16...d772aecc54
+6
-5
@@ -88,7 +88,8 @@ async function main() {
|
||||
await db.delete(schema.participants).where(sql`1=1`);
|
||||
await db.delete(schema.studyPlugins).where(sql`1=1`);
|
||||
await db.delete(schema.studyMembers).where(sql`1=1`);
|
||||
await db.delete(schema.studies).where(sql`1=1`);
|
||||
await db.delete(schema.formResponses).where(sql`1=1`);
|
||||
await db.delete(schema.forms).where(sql`1=1`);
|
||||
await db.delete(schema.studies).where(sql`1=1`);
|
||||
await db.delete(schema.plugins).where(sql`1=1`);
|
||||
await db.delete(schema.pluginRepositories).where(sql`1=1`);
|
||||
@@ -236,7 +237,7 @@ async function main() {
|
||||
description: "A comprehensive informed consent document template for HRI research studies.",
|
||||
isTemplate: true,
|
||||
templateName: "Informed Consent",
|
||||
version: 1,
|
||||
version: 100,
|
||||
fields: [
|
||||
{ id: "1", type: "text", label: "Study Title", required: true },
|
||||
{ id: "2", type: "text", label: "Principal Investigator Name", required: true },
|
||||
@@ -262,7 +263,7 @@ async function main() {
|
||||
description: "Standard questionnaire to collect participant feedback after HRI sessions.",
|
||||
isTemplate: true,
|
||||
templateName: "Post-Session Survey",
|
||||
version: 2,
|
||||
version: 101,
|
||||
fields: [
|
||||
{ id: "1", type: "rating", label: "How engaging was the robot?", required: true, settings: { scale: 5 } },
|
||||
{ id: "2", type: "rating", label: "How understandable was the robot's speech?", required: true, settings: { scale: 5 } },
|
||||
@@ -285,7 +286,7 @@ async function main() {
|
||||
description: "Basic demographic information collection form.",
|
||||
isTemplate: true,
|
||||
templateName: "Demographics",
|
||||
version: 3,
|
||||
version: 102,
|
||||
fields: [
|
||||
{ id: "1", type: "text", label: "Age", required: true },
|
||||
{ id: "2", type: "multiple_choice", label: "Gender", required: true, options: ["Male", "Female", "Non-binary", "Prefer not to say"] },
|
||||
@@ -305,8 +306,8 @@ async function main() {
|
||||
type: "consent",
|
||||
title: "Interactive Storyteller Consent",
|
||||
description: "Consent form for the Comparative WoZ Study - Interactive Storyteller scenario.",
|
||||
version: 1,
|
||||
active: true,
|
||||
version: 4,
|
||||
fields: [
|
||||
{ id: "1", type: "text", label: "Participant Name", required: true },
|
||||
{ id: "2", type: "date", label: "Date", required: true },
|
||||
|
||||
@@ -20,17 +20,16 @@ import {
|
||||
Users,
|
||||
CheckCircle,
|
||||
Printer,
|
||||
Download,
|
||||
Pencil,
|
||||
X,
|
||||
FileDown,
|
||||
} from "lucide-react";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -42,26 +41,11 @@ import { Badge } from "~/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Field {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
options?: string[];
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
|
||||
const fieldTypes = [
|
||||
{ value: "text", label: "Text (short)", icon: "📝" },
|
||||
{ value: "textarea", label: "Text (long)", icon: "📄" },
|
||||
{ value: "multiple_choice", label: "Multiple Choice", icon: "☑️" },
|
||||
{ value: "checkbox", label: "Checkbox", icon: "✅" },
|
||||
{ value: "rating", label: "Rating Scale", icon: "⭐" },
|
||||
{ value: "yes_no", label: "Yes/No", icon: "✔️" },
|
||||
{ value: "date", label: "Date", icon: "📅" },
|
||||
{ value: "signature", label: "Signature", icon: "✍️" },
|
||||
];
|
||||
import type { FormField, FormFieldType } from "~/lib/types/forms";
|
||||
import { FORM_FIELD_TYPES } from "~/lib/types/forms";
|
||||
import { formStatusColors } from "~/lib/constants";
|
||||
import { FormBuilder } from "~/components/forms/FormBuilder";
|
||||
import { FormFieldRenderer } from "~/components/forms/FormFieldRenderer";
|
||||
|
||||
const formTypeIcons = {
|
||||
consent: FileSignature,
|
||||
@@ -69,12 +53,6 @@ const formTypeIcons = {
|
||||
questionnaire: FileQuestion,
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
pending: "bg-yellow-100 text-yellow-700",
|
||||
completed: "bg-green-100 text-green-700",
|
||||
rejected: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
interface FormViewPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
@@ -99,7 +77,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [fields, setFields] = useState<Field[]>([]);
|
||||
const [fields, setFields] = useState<FormField[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const resolveParams = async () => {
|
||||
@@ -213,7 +191,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
'<div style="margin-top: 4px;"><input type="radio" name="yn" /> Yes <input type="radio" name="yn" /> No</div>';
|
||||
break;
|
||||
case "rating":
|
||||
const scale = field.settings?.scale || 5;
|
||||
const scale = (field.settings?.scale as number) || 5;
|
||||
inputField = `<div style="margin-top: 4px;">${Array.from(
|
||||
{ length: scale },
|
||||
(_, i) => `<input type="radio" name="rating" /> ${i + 1} `,
|
||||
@@ -284,7 +262,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
if (form) {
|
||||
setTitle(form.title);
|
||||
setDescription(form.description || "");
|
||||
setFields((form.fields as Field[]) || []);
|
||||
setFields((form.fields as FormField[]) || []);
|
||||
}
|
||||
}, [form]);
|
||||
|
||||
@@ -307,10 +285,10 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
const responses = responsesData?.responses ?? [];
|
||||
|
||||
const addField = (type: string) => {
|
||||
const newField: Field = {
|
||||
const newField: FormField = {
|
||||
id: crypto.randomUUID(),
|
||||
type,
|
||||
label: `New ${fieldTypes.find((f) => f.value === type)?.label || "Field"}`,
|
||||
type: type as FormFieldType,
|
||||
label: `New ${FORM_FIELD_TYPES.find((f) => f.value === type)?.label || "Field"}`,
|
||||
required: false,
|
||||
options:
|
||||
type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
|
||||
@@ -322,7 +300,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
setFields(fields.filter((f) => f.id !== id));
|
||||
};
|
||||
|
||||
const updateField = (id: string, updates: Partial<Field>) => {
|
||||
const updateField = (id: string, updates: Partial<FormField>) => {
|
||||
setFields(fields.map((f) => (f.id === id ? { ...f, ...updates } : f)));
|
||||
};
|
||||
|
||||
@@ -332,7 +310,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
title,
|
||||
description,
|
||||
fields,
|
||||
settings: form.settings as Record<string, any>,
|
||||
settings: form.settings as Record<string, unknown>,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -415,7 +393,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
<SelectValue placeholder="Add field..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldTypes.map((type) => (
|
||||
{FORM_FIELD_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<span className="mr-2">{type.icon}</span>
|
||||
{type.label}
|
||||
@@ -444,11 +422,11 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{
|
||||
fieldTypes.find((f) => f.value === field.type)
|
||||
FORM_FIELD_TYPES.find((f) => f.value === field.type)
|
||||
?.icon
|
||||
}{" "}
|
||||
{
|
||||
fieldTypes.find((f) => f.value === field.type)
|
||||
FORM_FIELD_TYPES.find((f) => f.value === field.type)
|
||||
?.label
|
||||
}
|
||||
</Badge>
|
||||
@@ -569,7 +547,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
<p className="font-medium">{field.label}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{
|
||||
fieldTypes.find((f) => f.value === field.type)
|
||||
FORM_FIELD_TYPES.find((f) => f.value === field.type)
|
||||
?.label
|
||||
}
|
||||
{field.required && " • Required"}
|
||||
@@ -646,7 +624,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
{field.type === "rating" && (
|
||||
<div className="flex gap-2">
|
||||
{Array.from(
|
||||
{ length: field.settings?.scale || 5 },
|
||||
{ length: (field.settings?.scale as number) || 5 },
|
||||
(_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
@@ -831,7 +809,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from(
|
||||
{ length: field.settings?.scale || 5 },
|
||||
{ length: (field.settings?.scale as number) || 5 },
|
||||
(_, i) => (
|
||||
<SelectItem key={i} value={String(i + 1)}>
|
||||
{i + 1}
|
||||
@@ -948,7 +926,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
className={`text-xs ${statusColors[response.status as keyof typeof statusColors]}`}
|
||||
className={`text-xs ${formStatusColors[response.status as keyof typeof formStatusColors]}`}
|
||||
>
|
||||
{response.status}
|
||||
</Badge>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useSession } from "~/lib/auth-client";
|
||||
import { notFound } from "next/navigation";
|
||||
@@ -8,22 +8,17 @@ import Link from "next/link";
|
||||
import {
|
||||
FileText,
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
Save,
|
||||
LayoutTemplate,
|
||||
FileSignature,
|
||||
ClipboardList,
|
||||
FileQuestion,
|
||||
Save,
|
||||
Copy,
|
||||
LayoutTemplate,
|
||||
} from "lucide-react";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -31,29 +26,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Field {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
options?: string[];
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
|
||||
const fieldTypes = [
|
||||
{ value: "text", label: "Text (short)", icon: "📝" },
|
||||
{ value: "textarea", label: "Text (long)", icon: "📄" },
|
||||
{ value: "multiple_choice", label: "Multiple Choice", icon: "☑️" },
|
||||
{ value: "checkbox", label: "Checkbox", icon: "✅" },
|
||||
{ value: "rating", label: "Rating Scale", icon: "⭐" },
|
||||
{ value: "yes_no", label: "Yes/No", icon: "✔️" },
|
||||
{ value: "date", label: "Date", icon: "📅" },
|
||||
{ value: "signature", label: "Signature", icon: "✍️" },
|
||||
];
|
||||
import type { FormField, FormType } from "~/lib/types/forms";
|
||||
import { FORM_FIELD_TYPES } from "~/lib/types/forms";
|
||||
import { FormBuilder } from "~/components/forms/FormBuilder";
|
||||
|
||||
const formTypes = [
|
||||
{ value: "consent", label: "Consent Form", icon: FileSignature, description: "Legal/IRB consent documents" },
|
||||
@@ -65,14 +42,13 @@ export default function NewFormPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const studyId = typeof params.id === "string" ? params.id : "";
|
||||
|
||||
const [formType, setFormType] = useState<string>("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [fields, setFields] = useState<Field[]>([]);
|
||||
const [fields, setFields] = useState<FormField[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { data: study } = api.studies.get.useQuery(
|
||||
@@ -115,25 +91,6 @@ export default function NewFormPage() {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const addField = (type: string) => {
|
||||
const newField: Field = {
|
||||
id: crypto.randomUUID(),
|
||||
type,
|
||||
label: `New ${fieldTypes.find(f => f.value === type)?.label || "Field"}`,
|
||||
required: false,
|
||||
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
|
||||
};
|
||||
setFields([...fields, newField]);
|
||||
};
|
||||
|
||||
const removeField = (id: string) => {
|
||||
setFields(fields.filter(f => f.id !== id));
|
||||
};
|
||||
|
||||
const updateField = (id: string, updates: Partial<Field>) => {
|
||||
setFields(fields.map(f => f.id === id ? { ...f, ...updates } : f));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -145,7 +102,7 @@ export default function NewFormPage() {
|
||||
setIsSubmitting(true);
|
||||
createForm.mutate({
|
||||
studyId,
|
||||
type: formType as "consent" | "survey" | "questionnaire",
|
||||
type: formType as FormType,
|
||||
title,
|
||||
description,
|
||||
fields,
|
||||
@@ -266,12 +223,21 @@ export default function NewFormPage() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Form Fields</CardTitle>
|
||||
<Select onValueChange={addField}>
|
||||
<Select onValueChange={(type) => {
|
||||
const newField: FormField = {
|
||||
id: crypto.randomUUID(),
|
||||
type: type as FormField["type"],
|
||||
label: `New ${FORM_FIELD_TYPES.find(f => f.value === type)?.label || "Field"}`,
|
||||
required: false,
|
||||
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
|
||||
};
|
||||
setFields([...fields, newField]);
|
||||
}}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Add field..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldTypes.map((type) => (
|
||||
{FORM_FIELD_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<span className="mr-2">{type.icon}</span>
|
||||
{type.label}
|
||||
@@ -281,117 +247,7 @@ export default function NewFormPage() {
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{fields.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
||||
<FileText className="mb-2 h-8 w-8" />
|
||||
<p>No fields added yet</p>
|
||||
<p className="text-sm">Use the dropdown above to add fields</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-start gap-3 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex cursor-grab items-center text-muted-foreground">
|
||||
<GripVertical className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{fieldTypes.find(f => f.value === field.type)?.icon}{" "}
|
||||
{fieldTypes.find(f => f.value === field.type)?.label}
|
||||
</Badge>
|
||||
<Input
|
||||
value={field.label}
|
||||
onChange={(e) => updateField(field.id, { label: e.target.value })}
|
||||
placeholder="Field label"
|
||||
className="flex-1"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={(e) => updateField(field.id, { required: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
</div>
|
||||
{field.type === "multiple_choice" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Options</Label>
|
||||
{field.options?.map((opt, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={opt}
|
||||
onChange={(e) => {
|
||||
const newOptions = [...(field.options || [])];
|
||||
newOptions[i] = e.target.value;
|
||||
updateField(field.id, { options: newOptions });
|
||||
}}
|
||||
placeholder={`Option ${i + 1}`}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const newOptions = field.options?.filter((_, idx) => idx !== i);
|
||||
updateField(field.id, { options: newOptions });
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newOptions = [...(field.options || []), `Option ${(field.options?.length || 0) + 1}`];
|
||||
updateField(field.id, { options: newOptions });
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add Option
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{field.type === "rating" && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Scale:</span>
|
||||
<Select
|
||||
value={field.settings?.scale?.toString() || "5"}
|
||||
onValueChange={(val) => updateField(field.id, { settings: { scale: parseInt(val) } })}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">1-5</SelectItem>
|
||||
<SelectItem value="7">1-7</SelectItem>
|
||||
<SelectItem value="10">1-10</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeField(field.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FormBuilder fields={fields} onFieldsChange={setFields} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -407,4 +263,4 @@ export default function NewFormPage() {
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
FileText,
|
||||
@@ -22,18 +22,10 @@ import {
|
||||
} from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Field {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
options?: string[];
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
import type { FormField } from "~/lib/types/forms";
|
||||
import { FormFieldRenderer } from "~/components/forms/FormFieldRenderer";
|
||||
|
||||
const formTypeIcons = {
|
||||
consent: FileSignature,
|
||||
@@ -47,7 +39,7 @@ export default function ParticipantFormPage() {
|
||||
const formId = params.formId as string;
|
||||
|
||||
const [participantCode, setParticipantCode] = useState("");
|
||||
const [formResponses, setFormResponses] = useState<Record<string, any>>({});
|
||||
const [formResponses, setFormResponses] = useState<Record<string, unknown>>({});
|
||||
const [hasSubmitted, setHasSubmitted] = useState(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -113,7 +105,7 @@ export default function ParticipantFormPage() {
|
||||
|
||||
const TypeIcon =
|
||||
formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText;
|
||||
const fields = (form.fields as Field[]) || [];
|
||||
const fields = (form.fields as FormField[]) || [];
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const errors: Record<string, string> = {};
|
||||
@@ -158,7 +150,7 @@ export default function ParticipantFormPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const updateResponse = (fieldId: string, value: any) => {
|
||||
const updateResponse = (fieldId: string, value: unknown) => {
|
||||
setFormResponses({ ...formResponses, [fieldId]: value });
|
||||
if (fieldErrors[fieldId]) {
|
||||
const newErrors = { ...fieldErrors };
|
||||
@@ -217,175 +209,21 @@ export default function ParticipantFormPage() {
|
||||
<div className="border-t pt-6">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="mb-6 last:mb-0">
|
||||
<Label
|
||||
htmlFor={field.id}
|
||||
className={
|
||||
fieldErrors[field.id] ? "text-destructive" : ""
|
||||
}
|
||||
>
|
||||
{index + 1}. {field.label}
|
||||
{field.required && (
|
||||
<span className="text-destructive"> *</span>
|
||||
)}
|
||||
</Label>
|
||||
<FormFieldLabel
|
||||
field={field}
|
||||
index={index}
|
||||
showIndex
|
||||
/>
|
||||
|
||||
<div className="mt-2">
|
||||
{field.type === "text" && (
|
||||
<Input
|
||||
id={field.id}
|
||||
value={formResponses[field.id] || ""}
|
||||
onChange={(e) =>
|
||||
updateResponse(field.id, e.target.value)
|
||||
}
|
||||
placeholder="Enter your response..."
|
||||
className={
|
||||
fieldErrors[field.id] ? "border-destructive" : ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === "textarea" && (
|
||||
<Textarea
|
||||
id={field.id}
|
||||
value={formResponses[field.id] || ""}
|
||||
onChange={(e) =>
|
||||
updateResponse(field.id, e.target.value)
|
||||
}
|
||||
placeholder="Enter your response..."
|
||||
className={
|
||||
fieldErrors[field.id] ? "border-destructive" : ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === "multiple_choice" && (
|
||||
<div
|
||||
className={`mt-2 space-y-2 ${fieldErrors[field.id] ? "border-destructive rounded-md border p-2" : ""}`}
|
||||
>
|
||||
{field.options?.map((opt, i) => (
|
||||
<label
|
||||
key={i}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={field.id}
|
||||
value={opt}
|
||||
checked={formResponses[field.id] === opt}
|
||||
onChange={() => updateResponse(field.id, opt)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm">{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.type === "checkbox" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={field.id}
|
||||
checked={formResponses[field.id] || false}
|
||||
onChange={(e) =>
|
||||
updateResponse(field.id, e.target.checked)
|
||||
}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={field.id}
|
||||
className="cursor-pointer font-normal"
|
||||
>
|
||||
Yes, I agree
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.type === "yes_no" && (
|
||||
<div className="mt-2 flex gap-4">
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={field.id}
|
||||
value="yes"
|
||||
checked={formResponses[field.id] === "yes"}
|
||||
onChange={() => updateResponse(field.id, "yes")}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm">Yes</span>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={field.id}
|
||||
value="no"
|
||||
checked={formResponses[field.id] === "no"}
|
||||
onChange={() => updateResponse(field.id, "no")}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm">No</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.type === "rating" && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{Array.from(
|
||||
{ length: field.settings?.scale || 5 },
|
||||
(_, i) => (
|
||||
<label key={i} className="cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={field.id}
|
||||
value={String(i + 1)}
|
||||
checked={formResponses[field.id] === i + 1}
|
||||
onChange={() =>
|
||||
updateResponse(field.id, i + 1)
|
||||
}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span className="hover:bg-muted peer-checked:bg-primary peer-checked:text-primary-foreground flex h-10 w-10 items-center justify-center rounded-full border text-sm font-medium transition-colors">
|
||||
{i + 1}
|
||||
</span>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.type === "date" && (
|
||||
<Input
|
||||
type="date"
|
||||
id={field.id}
|
||||
value={formResponses[field.id] || ""}
|
||||
onChange={(e) =>
|
||||
updateResponse(field.id, e.target.value)
|
||||
}
|
||||
className={
|
||||
fieldErrors[field.id] ? "border-destructive" : ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === "signature" && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
id={field.id}
|
||||
value={formResponses[field.id] || ""}
|
||||
onChange={(e) =>
|
||||
updateResponse(field.id, e.target.value)
|
||||
}
|
||||
placeholder="Type your full name as signature"
|
||||
className={
|
||||
fieldErrors[field.id] ? "border-destructive" : ""
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
By entering your name above, you confirm that the
|
||||
information provided is accurate.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<FormFieldRenderer
|
||||
field={field}
|
||||
value={formResponses[field.id]}
|
||||
onChange={(val) => updateResponse(field.id, val)}
|
||||
mode="participant"
|
||||
index={index}
|
||||
error={fieldErrors[field.id]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{fieldErrors[field.id] && (
|
||||
@@ -428,3 +266,25 @@ export default function ParticipantFormPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormFieldLabel({
|
||||
field,
|
||||
index,
|
||||
showIndex = true,
|
||||
error,
|
||||
}: {
|
||||
field: FormField;
|
||||
index: number;
|
||||
showIndex?: boolean;
|
||||
error?: string;
|
||||
}) {
|
||||
return (
|
||||
<Label
|
||||
className={error ? "text-destructive" : ""}
|
||||
>
|
||||
{showIndex && `${index + 1}. `}
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive"> *</span>}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,7 +138,6 @@ export default function SignInPage() {
|
||||
id="not-robot"
|
||||
checked={notRobot}
|
||||
onCheckedChange={(checked) => setNotRobot(checked === true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<label
|
||||
htmlFor="not-robot"
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
// Define error type for mutations
|
||||
interface TRPCError {
|
||||
@@ -101,131 +100,37 @@ const syncStatusConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
function RepositoryActionsCell({ repository }: { repository: Repository }) {
|
||||
const utils = api.useUtils();
|
||||
|
||||
const syncMutation = api.admin.repositories.sync.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Repository sync started");
|
||||
void utils.admin.repositories.list.invalidate();
|
||||
},
|
||||
onError: (error: TRPCError) => {
|
||||
toast.error(error.message ?? "Failed to sync repository");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = api.admin.repositories.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Repository deleted successfully");
|
||||
void utils.admin.repositories.list.invalidate();
|
||||
},
|
||||
onError: (error: TRPCError) => {
|
||||
toast.error(error.message ?? "Failed to delete repository");
|
||||
},
|
||||
});
|
||||
|
||||
const handleSync = async () => {
|
||||
syncMutation.mutate({ id: repository.id });
|
||||
function RepositoryUrlCell({ url }: { url: string }) {
|
||||
const handleCopy = () => {
|
||||
void navigator.clipboard.writeText(url);
|
||||
toast.success("URL copied to clipboard");
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (
|
||||
window.confirm(`Are you sure you want to delete "${repository.name}"?`)
|
||||
) {
|
||||
deleteMutation.mutate({ id: repository.id });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyId = () => {
|
||||
void navigator.clipboard.writeText(repository.id);
|
||||
toast.success("Repository ID copied to clipboard");
|
||||
};
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
void navigator.clipboard.writeText(repository.url);
|
||||
toast.success("Repository URL copied to clipboard");
|
||||
};
|
||||
|
||||
const canDelete = !repository.isOfficial;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={handleSync}
|
||||
disabled={syncMutation.isPending}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={handleCopy}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
{url && (
|
||||
<Link
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${syncMutation.isPending ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Sync Repository
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/repositories/${repository.id}/edit`}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Edit Repository
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={repository.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
View Repository
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={handleCopyId}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Repository ID
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleCopyUrl}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Repository URL
|
||||
</DropdownMenuItem>
|
||||
|
||||
{canDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Repository
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ExternalLink className="mr-1 h-3 w-3" />
|
||||
Visit
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const repositoriesColumns: ColumnDef<Repository>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="#" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
@@ -240,34 +145,16 @@ export const repositoriesColumns: ColumnDef<Repository>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Repository Name" />
|
||||
<DataTableColumnHeader column={column} title="Repository" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const repository = row.original;
|
||||
return (
|
||||
<div className="max-w-[200px] min-w-0 space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Database className="text-muted-foreground h-4 w-4 flex-shrink-0" />
|
||||
<Link
|
||||
href={`/admin/repositories/${repository.id}`}
|
||||
className="truncate font-medium hover:underline"
|
||||
title={repository.name}
|
||||
>
|
||||
{repository.name}
|
||||
</Link>
|
||||
{repository.isOfficial && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Official
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{repository.description && (
|
||||
<p
|
||||
className="text-muted-foreground line-clamp-1 truncate text-sm"
|
||||
title={repository.description}
|
||||
>
|
||||
{repository.description}
|
||||
</p>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{row.original.name}</span>
|
||||
{row.original.description && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.original.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -276,22 +163,11 @@ export const repositoriesColumns: ColumnDef<Repository>[] = [
|
||||
{
|
||||
accessorKey: "url",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Repository URL" />
|
||||
<DataTableColumnHeader column={column} title="URL" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<RepositoryUrlCell url={row.original.url} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const url = row.original.url;
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="max-w-[300px] truncate text-sm text-blue-600 hover:underline"
|
||||
title={url}
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "trustLevel",
|
||||
@@ -327,25 +203,15 @@ export const repositoriesColumns: ColumnDef<Repository>[] = [
|
||||
const isEnabled = row.original.isEnabled;
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
isEnabled
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}
|
||||
variant={isEnabled ? "default" : "secondary"}
|
||||
className={isEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"}
|
||||
>
|
||||
{isEnabled ? (
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{isEnabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value: string[]) => {
|
||||
const isEnabled = row.original.isEnabled;
|
||||
return value.includes(isEnabled ? "enabled" : "disabled");
|
||||
return value.includes(row.original.isEnabled ? "enabled" : "disabled");
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -354,80 +220,97 @@ export const repositoriesColumns: ColumnDef<Repository>[] = [
|
||||
<DataTableColumnHeader column={column} title="Sync Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const syncStatus = row.original.syncStatus;
|
||||
const lastSyncAt = row.original.lastSyncAt;
|
||||
const syncError = row.original.syncError;
|
||||
|
||||
if (!syncStatus) return "-";
|
||||
|
||||
const config =
|
||||
syncStatusConfig[syncStatus as keyof typeof syncStatusConfig];
|
||||
if (!config) return syncStatus;
|
||||
|
||||
const SyncIcon = config.icon;
|
||||
const status = row.original.syncStatus || "pending";
|
||||
const config = syncStatusConfig[status as keyof typeof syncStatusConfig];
|
||||
const StatusIcon = config?.icon ?? Clock;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={config.className}
|
||||
title={config.description}
|
||||
>
|
||||
<SyncIcon
|
||||
className={`mr-1 h-3 w-3 ${syncStatus === "syncing" ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{config.label}
|
||||
</Badge>
|
||||
{lastSyncAt && syncStatus === "completed" && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{formatDistanceToNow(lastSyncAt, { addSuffix: true })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{config && (
|
||||
<Badge variant="secondary" className={config.className}>
|
||||
<StatusIcon className="mr-1 h-3 w-3" />
|
||||
{config.label}
|
||||
</Badge>
|
||||
)}
|
||||
{syncError && syncStatus === "failed" && (
|
||||
<div
|
||||
className="max-w-[150px] truncate text-xs text-red-600"
|
||||
title={syncError}
|
||||
{row.original.syncError && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto whitespace-normal text-xs text-destructive"
|
||||
title={row.original.syncError}
|
||||
>
|
||||
{syncError}
|
||||
</div>
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
Error
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.original.createdAt;
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
</div>
|
||||
);
|
||||
filterFn: (row, id, value: string[]) => {
|
||||
const status = row.original.syncStatus || "pending";
|
||||
return value.includes(status);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "updatedAt",
|
||||
accessorKey: "lastSyncAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Updated" />
|
||||
<DataTableColumnHeader column={column} title="Last Sync" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.original.updatedAt;
|
||||
const lastSync = row.original.lastSyncAt;
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{lastSync ? formatDistanceToNow(lastSync, { addSuffix: true }) : "Never"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
sortingFn: "datetime",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => <RepositoryActionsCell repository={row.original} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const repository = row.original;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(repository.id)}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/repositories/${repository.id}`}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
disabled={!repository.url}
|
||||
>
|
||||
<Link href={repository.url ?? "#"} target="_blank">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Visit Repository
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{!repository.isOfficial && (
|
||||
<DropdownMenuItem className="text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Repository
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { api } from "~/trpc/react";
|
||||
import { formatBytes } from "~/lib/utils";
|
||||
|
||||
export function SystemStats() {
|
||||
const { data: stats, isLoading } = api.admin.getSystemStats.useQuery({});
|
||||
@@ -25,14 +26,6 @@ export function SystemStats() {
|
||||
);
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const formatUptime = (seconds: number) => {
|
||||
const d = Math.floor(seconds / (3600 * 24));
|
||||
const h = Math.floor((seconds % (3600 * 24)) / 3600);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
UserCheck,
|
||||
Users,
|
||||
FileText,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useSidebar } from "~/components/ui/sidebar";
|
||||
@@ -156,7 +157,7 @@ export function AppSidebar({
|
||||
isLoadingUserStudies,
|
||||
} = useStudyManagement();
|
||||
|
||||
const { startTour, isTourActive } = useTour();
|
||||
const { startTour, stopTour, isTourActive } = useTour();
|
||||
|
||||
// Reference to track if we've already attempted auto-selection to avoid fighting with manual clearing
|
||||
const hasAutoSelected = useRef(false);
|
||||
@@ -308,12 +309,21 @@ export function AppSidebar({
|
||||
</SidebarMenu>
|
||||
{isTourActive && !isCollapsed && (
|
||||
<div className="mt-1 px-3 pb-2">
|
||||
<div className="bg-primary/10 text-primary border-primary/20 animate-in fade-in slide-in-from-top-2 flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium shadow-sm">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
|
||||
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full"></span>
|
||||
</span>
|
||||
Tutorial Active
|
||||
<div className="bg-primary/10 text-primary border-primary/20 animate-in fade-in slide-in-from-top-2 flex items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
|
||||
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full"></span>
|
||||
</span>
|
||||
Tutorial Active
|
||||
</div>
|
||||
<button
|
||||
onClick={stopTour}
|
||||
className="text-primary/60 hover:text-primary hover:bg-primary/10 rounded p-0.5 transition-colors"
|
||||
title="Cancel tutorial"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Trash2, Plus, GripVertical } from "lucide-react";
|
||||
import type { FormField, FormFieldType } from "~/lib/types/forms";
|
||||
import { FORM_FIELD_TYPES } from "~/lib/types/forms";
|
||||
|
||||
interface FormBuilderProps {
|
||||
fields: FormField[];
|
||||
onFieldsChange: (fields: FormField[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function FormBuilder({ fields, onFieldsChange, disabled = false }: FormBuilderProps) {
|
||||
const addField = (type: string) => {
|
||||
const newField: FormField = {
|
||||
id: crypto.randomUUID(),
|
||||
type: type as FormFieldType,
|
||||
label: `New ${FORM_FIELD_TYPES.find((f) => f.value === type)?.label || "Field"}`,
|
||||
required: false,
|
||||
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
|
||||
};
|
||||
onFieldsChange([...fields, newField]);
|
||||
};
|
||||
|
||||
const removeField = (id: string) => {
|
||||
onFieldsChange(fields.filter((f) => f.id !== id));
|
||||
};
|
||||
|
||||
const updateField = (id: string, updates: Partial<FormField>) => {
|
||||
onFieldsChange(fields.map((f) => (f.id === id ? { ...f, ...updates } : f)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{fields.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
||||
<p>No fields added yet</p>
|
||||
<p className="text-sm">Use the dropdown below to add fields</p>
|
||||
</div>
|
||||
) : (
|
||||
fields.map((field) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-start gap-3 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex cursor-grab items-center text-muted-foreground">
|
||||
<GripVertical className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{FORM_FIELD_TYPES.find((f) => f.value === field.type)?.icon}{" "}
|
||||
{FORM_FIELD_TYPES.find((f) => f.value === field.type)?.label}
|
||||
</Badge>
|
||||
<Input
|
||||
value={field.label}
|
||||
onChange={(e) => updateField(field.id, { label: e.target.value })}
|
||||
placeholder="Field label"
|
||||
className="flex-1"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={(e) => updateField(field.id, { required: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
disabled={disabled}
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
</div>
|
||||
{field.type === "multiple_choice" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Options</Label>
|
||||
{field.options?.map((opt, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={opt}
|
||||
onChange={(e) => {
|
||||
const newOptions = [...(field.options || [])];
|
||||
newOptions[i] = e.target.value;
|
||||
updateField(field.id, { options: newOptions });
|
||||
}}
|
||||
placeholder={`Option ${i + 1}`}
|
||||
className="flex-1"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const newOptions = field.options?.filter((_, idx) => idx !== i);
|
||||
updateField(field.id, { options: newOptions });
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newOptions = [
|
||||
...(field.options || []),
|
||||
`Option ${(field.options?.length || 0) + 1}`,
|
||||
];
|
||||
updateField(field.id, { options: newOptions });
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add Option
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{field.type === "rating" && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Scale:</span>
|
||||
<Select
|
||||
value={String(field.settings?.scale || 5)}
|
||||
onValueChange={(val) =>
|
||||
updateField(field.id, { settings: { scale: parseInt(val) } })
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">1-5</SelectItem>
|
||||
<SelectItem value="7">1-7</SelectItem>
|
||||
<SelectItem value="10">1-10</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeField(field.id)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
"use client";
|
||||
|
||||
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 type { FormField } from "~/lib/types/forms";
|
||||
import { FORM_FIELD_TYPES } from "~/lib/types/forms";
|
||||
|
||||
interface FormFieldRendererProps {
|
||||
field: FormField;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
mode: "preview" | "data-entry" | "participant";
|
||||
index: number;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function FormFieldRenderer({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
mode,
|
||||
index,
|
||||
error,
|
||||
disabled = false,
|
||||
}: FormFieldRendererProps) {
|
||||
const handleChange = (val: unknown) => {
|
||||
if (!disabled) {
|
||||
onChange(val);
|
||||
}
|
||||
};
|
||||
|
||||
const commonProps = {
|
||||
disabled,
|
||||
className: error ? "border-destructive" : "",
|
||||
};
|
||||
|
||||
const scale = (field.settings?.scale as number) || 5;
|
||||
|
||||
switch (field.type) {
|
||||
case "text":
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="Enter your response..."
|
||||
/>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<Textarea
|
||||
{...commonProps}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="Enter your response..."
|
||||
/>
|
||||
);
|
||||
|
||||
case "multiple_choice": {
|
||||
const containerClass =
|
||||
mode === "participant"
|
||||
? `mt-2 space-y-2 ${error ? "border-destructive rounded-md border p-2" : ""}`
|
||||
: "space-y-2";
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
{field.options?.map((opt, i) => (
|
||||
<label key={i} className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={field.id}
|
||||
value={opt}
|
||||
checked={value === opt}
|
||||
onChange={() => handleChange(opt)}
|
||||
disabled={disabled}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm">{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "checkbox":
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => handleChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
{mode === "participant" && (
|
||||
<Label className="cursor-pointer font-normal">Yes, I agree</Label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "yes_no":
|
||||
if (mode === "data-entry") {
|
||||
return (
|
||||
<Select
|
||||
value={String(value ?? "")}
|
||||
onValueChange={(val) => handleChange(val)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="yes">Yes</SelectItem>
|
||||
<SelectItem value="no">No</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={field.id}
|
||||
value="yes"
|
||||
checked={value === "yes"}
|
||||
onChange={() => handleChange("yes")}
|
||||
disabled={disabled}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm">Yes</span>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={field.id}
|
||||
value="no"
|
||||
checked={value === "no"}
|
||||
onChange={() => handleChange("no")}
|
||||
disabled={disabled}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm">No</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "rating": {
|
||||
const scale = field.settings?.scale || 5;
|
||||
if (mode === "data-entry") {
|
||||
return (
|
||||
<Select
|
||||
value={String(value ?? "")}
|
||||
onValueChange={(val) => handleChange(parseInt(val))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select rating..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: scale }, (_, i) => (
|
||||
<SelectItem key={i} value={String(i + 1)}>
|
||||
{i + 1}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
if (mode === "participant") {
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{Array.from({ length: scale }, (_, i) => (
|
||||
<label key={i} className="cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={field.id}
|
||||
value={String(i + 1)}
|
||||
checked={value === i + 1}
|
||||
onChange={() => handleChange(i + 1)}
|
||||
disabled={disabled}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span className="hover:bg-muted peer-checked:bg-primary peer-checked:text-primary-foreground flex h-10 w-10 items-center justify-center rounded-full border text-sm font-medium transition-colors">
|
||||
{i + 1}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{Array.from({ length: scale }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="disabled h-8 w-8 rounded border"
|
||||
disabled
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "date":
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
{...commonProps}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "signature":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
{...commonProps}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={
|
||||
mode === "participant"
|
||||
? "Type your full name as signature"
|
||||
: "Type name as signature..."
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
By entering your name above, you confirm that the information
|
||||
provided is accurate.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface FormFieldLabelProps {
|
||||
field: FormField;
|
||||
index: number;
|
||||
showIndex?: boolean;
|
||||
}
|
||||
|
||||
export function FormFieldLabel({
|
||||
field,
|
||||
index,
|
||||
showIndex = true,
|
||||
}: FormFieldLabelProps) {
|
||||
const fieldType = FORM_FIELD_TYPES.find((f) => f.value === field.type);
|
||||
return (
|
||||
<Label>
|
||||
{showIndex && `${index + 1}. `}
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive"> *</span>}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type TourType =
|
||||
|
||||
interface TourContextType {
|
||||
startTour: (tour: TourType) => void;
|
||||
stopTour: () => void;
|
||||
isTourActive: boolean;
|
||||
}
|
||||
|
||||
@@ -354,7 +355,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
||||
},
|
||||
})),
|
||||
onDestroyed: () => {
|
||||
// Persistence handled by localStorage state
|
||||
localStorage.removeItem("hristudio_tour_mode");
|
||||
Cookies.remove("hristudio_tour_mode");
|
||||
setIsTourActive(false);
|
||||
},
|
||||
});
|
||||
@@ -389,8 +391,18 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
};
|
||||
|
||||
const stopTour = () => {
|
||||
localStorage.removeItem("hristudio_tour_mode");
|
||||
Cookies.remove("hristudio_tour_mode");
|
||||
if (driverObj.current) {
|
||||
driverObj.current.destroy();
|
||||
driverObj.current = null;
|
||||
}
|
||||
setIsTourActive(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<TourContext.Provider value={{ startTour, isTourActive }}>
|
||||
<TourContext.Provider value={{ startTour, stopTour, isTourActive }}>
|
||||
{children}
|
||||
<style jsx global>{`
|
||||
/*
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
||||
import { trustLevelConfig } from "~/lib/constants";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -50,24 +51,6 @@ export type Plugin = {
|
||||
};
|
||||
};
|
||||
|
||||
const trustLevelConfig = {
|
||||
official: {
|
||||
label: "Official",
|
||||
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
|
||||
description: "Official HRIStudio plugin",
|
||||
},
|
||||
verified: {
|
||||
label: "Verified",
|
||||
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
||||
description: "Verified by the community",
|
||||
},
|
||||
community: {
|
||||
label: "Community",
|
||||
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
|
||||
description: "Community contributed",
|
||||
},
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
active: {
|
||||
label: "Active",
|
||||
|
||||
@@ -137,7 +137,7 @@ 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(),
|
||||
wizardId: z.string().uuid().optional().or(z.literal("")),
|
||||
notes: z.string().max(1000, "Notes cannot exceed 1000 characters").optional(),
|
||||
sessionNumber: z
|
||||
.number()
|
||||
@@ -269,8 +269,18 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
}
|
||||
}, [trial, mode, form]);
|
||||
|
||||
const createTrialMutation = api.trials.create.useMutation();
|
||||
const updateTrialMutation = api.trials.update.useMutation();
|
||||
const createTrialMutation = api.trials.create.useMutation({
|
||||
onError: (error) => {
|
||||
console.error("Create trial error:", error);
|
||||
setError(error.message);
|
||||
},
|
||||
});
|
||||
const updateTrialMutation = api.trials.update.useMutation({
|
||||
onError: (error) => {
|
||||
console.error("Update trial error:", error);
|
||||
setError(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
// Form submission
|
||||
const onSubmit = async (data: TrialFormData) => {
|
||||
@@ -283,7 +293,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
experimentId: data.experimentId,
|
||||
participantId: data.participantId,
|
||||
scheduledAt: data.scheduledAt,
|
||||
wizardId: data.wizardId,
|
||||
wizardId: data.wizardId || undefined,
|
||||
sessionNumber: data.sessionNumber ?? 1,
|
||||
notes: data.notes ?? undefined,
|
||||
});
|
||||
|
||||
@@ -506,6 +506,10 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
setTrial({ ...trial, status: data.status, startedAt: data.startedAt });
|
||||
setTrialStartTime(new Date());
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Start trial error:", error);
|
||||
toast.error("Failed to start trial", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const completeTrialMutation = api.trials.complete.useMutation({
|
||||
@@ -528,6 +532,10 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
onSuccess: (data) => {
|
||||
setTrial({ ...trial, status: data.status });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Abort trial error:", error);
|
||||
toast.error("Failed to abort trial", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const pauseTrialMutation = api.trials.pause.useMutation({
|
||||
@@ -1306,8 +1314,6 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
onStepSelect={handleStepSelect}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
onExecuteRobotAction={handleExecuteRobotAction}
|
||||
activeTab={executionPanelTab}
|
||||
onTabChange={setExecutionPanelTab}
|
||||
onSkipAction={handleSkipAction}
|
||||
isExecuting={isExecutingAction}
|
||||
onNextStep={handleNextStep}
|
||||
|
||||
@@ -3,85 +3,14 @@
|
||||
import React from "react";
|
||||
import { WizardActionItem } from "./WizardActionItem";
|
||||
import {
|
||||
Play,
|
||||
SkipForward,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
Zap,
|
||||
Loader2,
|
||||
Clock,
|
||||
RotateCcw,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
|
||||
interface StepData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional";
|
||||
parameters: Record<string, unknown>;
|
||||
conditions?: {
|
||||
options?: {
|
||||
label: string;
|
||||
value: string;
|
||||
nextStepId?: string;
|
||||
nextStepIndex?: number;
|
||||
variant?:
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "secondary"
|
||||
| "ghost"
|
||||
| "link";
|
||||
}[];
|
||||
};
|
||||
order: number;
|
||||
actions?: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string;
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
pluginId: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface TrialData {
|
||||
id: string;
|
||||
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
scheduledAt: Date | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
sessionNumber: number | null;
|
||||
notes: string | null;
|
||||
experimentId: string;
|
||||
participantId: string | null;
|
||||
wizardId: string | null;
|
||||
experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
studyId: string;
|
||||
};
|
||||
participant: {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
demographics: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface TrialEvent {
|
||||
type: string;
|
||||
timestamp: Date;
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
}
|
||||
import type { TrialData, StepData, TrialEvent } from "~/lib/types/trial";
|
||||
|
||||
interface WizardExecutionPanelProps {
|
||||
trial: TrialData;
|
||||
@@ -100,8 +29,6 @@ interface WizardExecutionPanelProps {
|
||||
parameters: Record<string, unknown>,
|
||||
options?: { autoAdvance?: boolean },
|
||||
) => Promise<void>;
|
||||
activeTab: "current" | "timeline" | "events"; // Deprecated/Ignored
|
||||
onTabChange: (tab: "current" | "timeline" | "events") => void; // Deprecated/Ignored
|
||||
onSkipAction: (
|
||||
pluginName: string,
|
||||
actionId: string,
|
||||
@@ -118,7 +45,7 @@ interface WizardExecutionPanelProps {
|
||||
rosConnected?: boolean;
|
||||
completedStepIndices?: Set<number>;
|
||||
skippedStepIndices?: Set<number>;
|
||||
onLogEvent?: (type: string, data?: any) => void;
|
||||
onLogEvent?: (type: string, data?: unknown) => void;
|
||||
}
|
||||
|
||||
export function WizardExecutionPanel({
|
||||
@@ -130,8 +57,6 @@ export function WizardExecutionPanel({
|
||||
onStepSelect,
|
||||
onExecuteAction,
|
||||
onExecuteRobotAction,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
onSkipAction,
|
||||
isExecuting = false,
|
||||
onNextStep,
|
||||
|
||||
@@ -153,7 +153,13 @@ export function EntityForm<T extends FieldValues = FieldValues>({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit(e).catch((err) => console.error("handleSubmit error:", err));
|
||||
}}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Form Fields */}
|
||||
{children}
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* @file useActiveStudy.ts
|
||||
*
|
||||
* Legacy placeholder for the deprecated `useActiveStudy` hook.
|
||||
*
|
||||
* This file exists solely to satisfy lingering TypeScript project
|
||||
* service references (e.g. editor cached import paths) after the
|
||||
* migration to the unified `useSelectedStudyDetails` hook.
|
||||
*
|
||||
* Previous responsibilities:
|
||||
* - Exposed the currently "active" study id via localStorage.
|
||||
* - Partially overlapped with a separate study context implementation.
|
||||
*
|
||||
* Migration:
|
||||
* - All consumers should now import `useSelectedStudyDetails` from:
|
||||
* `~/hooks/useSelectedStudyDetails`
|
||||
* - That hook centralizes selection, metadata, counts, and role info.
|
||||
*
|
||||
* Safe Removal:
|
||||
* - Once you are certain no editors / build artifacts reference this
|
||||
* path, you may delete this file. It is intentionally tiny and has
|
||||
* zero runtime footprint unless mistakenly invoked.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @deprecated Use `useSelectedStudyDetails()` instead.
|
||||
* Legacy no-op placeholder retained only to satisfy stale references.
|
||||
* Returns a neutral object so accidental invocations are harmless.
|
||||
*/
|
||||
export function useActiveStudy(): DeprecatedActiveStudyHookReturn {
|
||||
return { studyId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Type alias maintained for backward compatibility with (now removed)
|
||||
* code that might have referenced the old hook's return type.
|
||||
* Kept minimal on purpose.
|
||||
*/
|
||||
export interface DeprecatedActiveStudyHookReturn {
|
||||
/** Previously the active study id (now: studyId in useSelectedStudyDetails) */
|
||||
studyId: string | null;
|
||||
}
|
||||
|
||||
export default useActiveStudy;
|
||||
@@ -4,18 +4,12 @@ import { signOut } from "~/lib/auth-client";
|
||||
import { toast } from "sonner";
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
|
||||
/**
|
||||
* Auth error codes that should trigger automatic logout
|
||||
*/
|
||||
const AUTH_ERROR_CODES = [
|
||||
"UNAUTHORIZED",
|
||||
"FORBIDDEN",
|
||||
"UNAUTHENTICATED",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Auth error messages that should trigger automatic logout
|
||||
*/
|
||||
const AUTH_ERROR_MESSAGES = [
|
||||
"unauthorized",
|
||||
"unauthenticated",
|
||||
@@ -27,15 +21,10 @@ const AUTH_ERROR_MESSAGES = [
|
||||
"access denied",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Checks if an error is an authentication/authorization error that should trigger logout
|
||||
*/
|
||||
export function isAuthError(error: unknown): boolean {
|
||||
if (!error) return false;
|
||||
|
||||
// Check TRPC errors
|
||||
if (error instanceof TRPCClientError) {
|
||||
// Check error code
|
||||
const trpcErrorData = error.data as
|
||||
| { code?: string; httpStatus?: number }
|
||||
| undefined;
|
||||
@@ -47,24 +36,20 @@ export function isAuthError(error: unknown): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check HTTP status codes
|
||||
const httpStatus = trpcErrorData?.httpStatus;
|
||||
if (httpStatus === 401 || httpStatus === 403) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check error message
|
||||
const message = error.message?.toLowerCase() ?? "";
|
||||
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
||||
}
|
||||
|
||||
// Check generic errors
|
||||
if (error instanceof Error) {
|
||||
const message = error.message?.toLowerCase() || "";
|
||||
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
||||
}
|
||||
|
||||
// Check error objects with message property
|
||||
if (typeof error === "object" && error !== null) {
|
||||
if ("message" in error) {
|
||||
const errorObj = error as { message: unknown };
|
||||
@@ -72,7 +57,6 @@ export function isAuthError(error: unknown): boolean {
|
||||
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
||||
}
|
||||
|
||||
// Check for status codes in error objects
|
||||
if ("status" in error) {
|
||||
const statusObj = error as { status: unknown };
|
||||
const status = statusObj.status as number;
|
||||
@@ -83,9 +67,6 @@ export function isAuthError(error: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles authentication errors by logging out the user
|
||||
*/
|
||||
export async function handleAuthError(
|
||||
error: unknown,
|
||||
customMessage?: string,
|
||||
@@ -96,11 +77,9 @@ export async function handleAuthError(
|
||||
|
||||
console.warn("Authentication error detected, logging out user:", error);
|
||||
|
||||
// Show user-friendly message
|
||||
const message = customMessage ?? "Session expired. Please log in again.";
|
||||
toast.error(message);
|
||||
|
||||
// Small delay to let the toast show
|
||||
setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -108,72 +87,19 @@ export async function handleAuthError(
|
||||
window.location.href = "/";
|
||||
} catch (signOutError) {
|
||||
console.error("Error during sign out:", signOutError);
|
||||
// Force redirect if signOut fails
|
||||
window.location.href = "/";
|
||||
}
|
||||
})();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* React Query error handler that automatically handles auth errors
|
||||
*/
|
||||
export function createAuthErrorHandler(customMessage?: string) {
|
||||
return (error: unknown) => {
|
||||
void handleAuthError(error, customMessage);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* tRPC error handler that automatically handles auth errors
|
||||
*/
|
||||
export function handleTRPCError(error: unknown, customMessage?: string): void {
|
||||
void handleAuthError(error, customMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic error handler for any error type
|
||||
*/
|
||||
export function handleGenericError(
|
||||
error: unknown,
|
||||
customMessage?: string,
|
||||
): void {
|
||||
void handleAuthError(error, customMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook-style error handler for use in React components
|
||||
*/
|
||||
export function useAuthErrorHandler() {
|
||||
return {
|
||||
handleAuthError: (error: unknown, customMessage?: string) => {
|
||||
void handleAuthError(error, customMessage);
|
||||
},
|
||||
handleAuthError,
|
||||
isAuthError,
|
||||
createErrorHandler: createAuthErrorHandler,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher-order function to wrap API calls with automatic auth error handling
|
||||
*/
|
||||
export function withAuthErrorHandling<
|
||||
T extends (...args: unknown[]) => Promise<unknown>,
|
||||
>(fn: T, customMessage?: string): T {
|
||||
return (async (...args: Parameters<T>): Promise<ReturnType<T>> => {
|
||||
try {
|
||||
return (await fn(...args)) as ReturnType<T>;
|
||||
} catch (error) {
|
||||
await handleAuthError(error, customMessage);
|
||||
throw error; // Re-throw so calling code can handle it too
|
||||
}
|
||||
}) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to check if current error should show a generic error message
|
||||
* (i.e., it's not an auth error that will auto-logout)
|
||||
*/
|
||||
export function shouldShowGenericError(error: unknown): boolean {
|
||||
return !isAuthError(error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export const trustLevelConfig = {
|
||||
official: {
|
||||
label: "Official",
|
||||
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
|
||||
description: "Official HRIStudio plugin",
|
||||
},
|
||||
verified: {
|
||||
label: "Verified",
|
||||
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
||||
description: "Verified by the community",
|
||||
},
|
||||
community: {
|
||||
label: "Community",
|
||||
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
|
||||
description: "Community contributed",
|
||||
},
|
||||
};
|
||||
|
||||
export const statusConfig = {
|
||||
active: {
|
||||
label: "Active",
|
||||
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
||||
description: "Plugin is active and working",
|
||||
},
|
||||
deprecated: {
|
||||
label: "Deprecated",
|
||||
className: "bg-orange-100 text-orange-800 hover:bg-orange-200",
|
||||
description: "Plugin is deprecated",
|
||||
},
|
||||
inactive: {
|
||||
label: "Inactive",
|
||||
className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
|
||||
description: "Plugin is not active",
|
||||
},
|
||||
};
|
||||
|
||||
export const formStatusColors = {
|
||||
pending: "bg-yellow-100 text-yellow-700",
|
||||
completed: "bg-green-100 text-green-700",
|
||||
rejected: "bg-red-100 text-red-700",
|
||||
};
|
||||
@@ -464,6 +464,7 @@ export class WizardRosService extends EventEmitter {
|
||||
* Subscribe to robot sensor topics
|
||||
*/
|
||||
private subscribeToRobotTopics(): void {
|
||||
console.log("[WizardROS] Setting up robot topics...");
|
||||
const topics = [
|
||||
{ topic: "/joint_states", type: "sensor_msgs/JointState" },
|
||||
{ topic: "/bumper", type: "naoqi_bridge_msgs/Bumper" },
|
||||
@@ -476,6 +477,11 @@ export class WizardRosService extends EventEmitter {
|
||||
topics.forEach(({ topic, type }) => {
|
||||
this.subscribe(topic, type);
|
||||
});
|
||||
|
||||
this.advertise("/speech", "std_msgs/String");
|
||||
this.advertise("/cmd_vel", "geometry_msgs/Twist");
|
||||
this.advertise("/robot_pose", "geometry_msgs/Pose");
|
||||
this.advertise("/animation", "std_msgs/String");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -492,6 +498,21 @@ export class WizardRosService extends EventEmitter {
|
||||
this.send(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advertise a ROS topic (declare the type before publishing)
|
||||
*/
|
||||
private advertise(topic: string, messageType: string): void {
|
||||
console.log(`[WizardROS] Advertising topic ${topic} as ${messageType}`);
|
||||
const message: RosMessage = {
|
||||
op: "advertise",
|
||||
topic,
|
||||
type: messageType,
|
||||
id: `adv_${this.messageId++}`,
|
||||
};
|
||||
|
||||
this.send(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish message to ROS topic
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
export interface FormFieldSettings {
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
export interface FormField {
|
||||
id: string;
|
||||
type: FormFieldType;
|
||||
label: string;
|
||||
required: boolean;
|
||||
options?: string[];
|
||||
settings?: FormFieldSettings;
|
||||
}
|
||||
|
||||
export type FormFieldType =
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "multiple_choice"
|
||||
| "checkbox"
|
||||
| "rating"
|
||||
| "yes_no"
|
||||
| "date"
|
||||
| "signature";
|
||||
|
||||
export type FormType = "consent" | "survey" | "questionnaire";
|
||||
|
||||
export interface FormFieldTypeConfig {
|
||||
value: FormFieldType;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const FORM_FIELD_TYPES: FormFieldTypeConfig[] = [
|
||||
{ value: "text", label: "Text (short)", icon: "📝" },
|
||||
{ value: "textarea", label: "Text (long)", icon: "📄" },
|
||||
{ value: "multiple_choice", label: "Multiple Choice", icon: "☑️" },
|
||||
{ value: "checkbox", label: "Checkbox", icon: "✅" },
|
||||
{ value: "rating", label: "Rating Scale", icon: "⭐" },
|
||||
{ value: "yes_no", label: "Yes/No", icon: "✔️" },
|
||||
{ value: "date", label: "Date", icon: "📅" },
|
||||
{ value: "signature", label: "Signature", icon: "✍️" },
|
||||
];
|
||||
|
||||
export function createField(type: FormFieldType): FormField {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
type,
|
||||
label: `New ${FORM_FIELD_TYPES.find((f) => f.value === type)?.label || "Field"}`,
|
||||
required: false,
|
||||
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
export interface StepData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional";
|
||||
parameters: Record<string, unknown>;
|
||||
conditions?: {
|
||||
options?: {
|
||||
label: string;
|
||||
value: string;
|
||||
nextStepId?: string;
|
||||
nextStepIndex?: number;
|
||||
variant?:
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "secondary"
|
||||
| "ghost"
|
||||
| "link";
|
||||
}[];
|
||||
};
|
||||
order: number;
|
||||
actions?: ActionData[];
|
||||
}
|
||||
|
||||
export interface ActionData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string;
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
pluginId: string | null;
|
||||
}
|
||||
|
||||
export interface TrialData {
|
||||
id: string;
|
||||
status: TrialStatus;
|
||||
scheduledAt: Date | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
sessionNumber: number | null;
|
||||
notes: string | null;
|
||||
experimentId: string;
|
||||
participantId: string | null;
|
||||
wizardId: string | null;
|
||||
experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
studyId: string;
|
||||
};
|
||||
participant: {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
demographics: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type TrialStatus =
|
||||
| "scheduled"
|
||||
| "in_progress"
|
||||
| "completed"
|
||||
| "aborted"
|
||||
| "failed";
|
||||
|
||||
export interface TrialEvent {
|
||||
type: string;
|
||||
timestamp: Date;
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user