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,
|
url: env.DATABASE_URL,
|
||||||
},
|
},
|
||||||
tablesFilter: ["hs_*"],
|
tablesFilter: ["hs_*"],
|
||||||
|
out: "./migrations",
|
||||||
} satisfies Config;
|
} 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.participants).where(sql`1=1`);
|
||||||
await db.delete(schema.studyPlugins).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.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.studies).where(sql`1=1`);
|
||||||
await db.delete(schema.plugins).where(sql`1=1`);
|
await db.delete(schema.plugins).where(sql`1=1`);
|
||||||
await db.delete(schema.pluginRepositories).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.",
|
description: "A comprehensive informed consent document template for HRI research studies.",
|
||||||
isTemplate: true,
|
isTemplate: true,
|
||||||
templateName: "Informed Consent",
|
templateName: "Informed Consent",
|
||||||
version: 1,
|
version: 100,
|
||||||
fields: [
|
fields: [
|
||||||
{ id: "1", type: "text", label: "Study Title", required: true },
|
{ id: "1", type: "text", label: "Study Title", required: true },
|
||||||
{ id: "2", type: "text", label: "Principal Investigator Name", 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.",
|
description: "Standard questionnaire to collect participant feedback after HRI sessions.",
|
||||||
isTemplate: true,
|
isTemplate: true,
|
||||||
templateName: "Post-Session Survey",
|
templateName: "Post-Session Survey",
|
||||||
version: 2,
|
version: 101,
|
||||||
fields: [
|
fields: [
|
||||||
{ id: "1", type: "rating", label: "How engaging was the robot?", required: true, settings: { scale: 5 } },
|
{ 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 } },
|
{ 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.",
|
description: "Basic demographic information collection form.",
|
||||||
isTemplate: true,
|
isTemplate: true,
|
||||||
templateName: "Demographics",
|
templateName: "Demographics",
|
||||||
version: 3,
|
version: 102,
|
||||||
fields: [
|
fields: [
|
||||||
{ id: "1", type: "text", label: "Age", required: true },
|
{ 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"] },
|
{ 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",
|
type: "consent",
|
||||||
title: "Interactive Storyteller Consent",
|
title: "Interactive Storyteller Consent",
|
||||||
description: "Consent form for the Comparative WoZ Study - Interactive Storyteller scenario.",
|
description: "Consent form for the Comparative WoZ Study - Interactive Storyteller scenario.",
|
||||||
|
version: 1,
|
||||||
active: true,
|
active: true,
|
||||||
version: 4,
|
|
||||||
fields: [
|
fields: [
|
||||||
{ id: "1", type: "text", label: "Participant Name", required: true },
|
{ id: "1", type: "text", label: "Participant Name", required: true },
|
||||||
{ id: "2", type: "date", label: "Date", required: true },
|
{ id: "2", type: "date", label: "Date", required: true },
|
||||||
|
|||||||
@@ -20,17 +20,16 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Printer,
|
Printer,
|
||||||
Download,
|
|
||||||
Pencil,
|
Pencil,
|
||||||
X,
|
X,
|
||||||
FileDown,
|
FileDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { Textarea } from "~/components/ui/textarea";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -42,26 +41,11 @@ import { Badge } from "~/components/ui/badge";
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import type { FormField, FormFieldType } from "~/lib/types/forms";
|
||||||
interface Field {
|
import { FORM_FIELD_TYPES } from "~/lib/types/forms";
|
||||||
id: string;
|
import { formStatusColors } from "~/lib/constants";
|
||||||
type: string;
|
import { FormBuilder } from "~/components/forms/FormBuilder";
|
||||||
label: string;
|
import { FormFieldRenderer } from "~/components/forms/FormFieldRenderer";
|
||||||
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: "✍️" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const formTypeIcons = {
|
const formTypeIcons = {
|
||||||
consent: FileSignature,
|
consent: FileSignature,
|
||||||
@@ -69,12 +53,6 @@ const formTypeIcons = {
|
|||||||
questionnaire: FileQuestion,
|
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 {
|
interface FormViewPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -99,7 +77,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
|
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [fields, setFields] = useState<Field[]>([]);
|
const [fields, setFields] = useState<FormField[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const resolveParams = async () => {
|
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>';
|
'<div style="margin-top: 4px;"><input type="radio" name="yn" /> Yes <input type="radio" name="yn" /> No</div>';
|
||||||
break;
|
break;
|
||||||
case "rating":
|
case "rating":
|
||||||
const scale = field.settings?.scale || 5;
|
const scale = (field.settings?.scale as number) || 5;
|
||||||
inputField = `<div style="margin-top: 4px;">${Array.from(
|
inputField = `<div style="margin-top: 4px;">${Array.from(
|
||||||
{ length: scale },
|
{ length: scale },
|
||||||
(_, i) => `<input type="radio" name="rating" /> ${i + 1} `,
|
(_, i) => `<input type="radio" name="rating" /> ${i + 1} `,
|
||||||
@@ -284,7 +262,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
if (form) {
|
if (form) {
|
||||||
setTitle(form.title);
|
setTitle(form.title);
|
||||||
setDescription(form.description || "");
|
setDescription(form.description || "");
|
||||||
setFields((form.fields as Field[]) || []);
|
setFields((form.fields as FormField[]) || []);
|
||||||
}
|
}
|
||||||
}, [form]);
|
}, [form]);
|
||||||
|
|
||||||
@@ -307,10 +285,10 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
const responses = responsesData?.responses ?? [];
|
const responses = responsesData?.responses ?? [];
|
||||||
|
|
||||||
const addField = (type: string) => {
|
const addField = (type: string) => {
|
||||||
const newField: Field = {
|
const newField: FormField = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
type,
|
type: type as FormFieldType,
|
||||||
label: `New ${fieldTypes.find((f) => f.value === type)?.label || "Field"}`,
|
label: `New ${FORM_FIELD_TYPES.find((f) => f.value === type)?.label || "Field"}`,
|
||||||
required: false,
|
required: false,
|
||||||
options:
|
options:
|
||||||
type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
|
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));
|
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)));
|
setFields(fields.map((f) => (f.id === id ? { ...f, ...updates } : f)));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -332,7 +310,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
fields,
|
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..." />
|
<SelectValue placeholder="Add field..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{fieldTypes.map((type) => (
|
{FORM_FIELD_TYPES.map((type) => (
|
||||||
<SelectItem key={type.value} value={type.value}>
|
<SelectItem key={type.value} value={type.value}>
|
||||||
<span className="mr-2">{type.icon}</span>
|
<span className="mr-2">{type.icon}</span>
|
||||||
{type.label}
|
{type.label}
|
||||||
@@ -444,11 +422,11 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{
|
{
|
||||||
fieldTypes.find((f) => f.value === field.type)
|
FORM_FIELD_TYPES.find((f) => f.value === field.type)
|
||||||
?.icon
|
?.icon
|
||||||
}{" "}
|
}{" "}
|
||||||
{
|
{
|
||||||
fieldTypes.find((f) => f.value === field.type)
|
FORM_FIELD_TYPES.find((f) => f.value === field.type)
|
||||||
?.label
|
?.label
|
||||||
}
|
}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -569,7 +547,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
<p className="font-medium">{field.label}</p>
|
<p className="font-medium">{field.label}</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{
|
{
|
||||||
fieldTypes.find((f) => f.value === field.type)
|
FORM_FIELD_TYPES.find((f) => f.value === field.type)
|
||||||
?.label
|
?.label
|
||||||
}
|
}
|
||||||
{field.required && " • Required"}
|
{field.required && " • Required"}
|
||||||
@@ -646,7 +624,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
{field.type === "rating" && (
|
{field.type === "rating" && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{Array.from(
|
{Array.from(
|
||||||
{ length: field.settings?.scale || 5 },
|
{ length: (field.settings?.scale as number) || 5 },
|
||||||
(_, i) => (
|
(_, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
@@ -831,7 +809,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{Array.from(
|
{Array.from(
|
||||||
{ length: field.settings?.scale || 5 },
|
{ length: (field.settings?.scale as number) || 5 },
|
||||||
(_, i) => (
|
(_, i) => (
|
||||||
<SelectItem key={i} value={String(i + 1)}>
|
<SelectItem key={i} value={String(i + 1)}>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
@@ -948,7 +926,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
className={`text-xs ${statusColors[response.status as keyof typeof statusColors]}`}
|
className={`text-xs ${formStatusColors[response.status as keyof typeof formStatusColors]}`}
|
||||||
>
|
>
|
||||||
{response.status}
|
{response.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useSession } from "~/lib/auth-client";
|
import { useSession } from "~/lib/auth-client";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
@@ -8,22 +8,17 @@ import Link from "next/link";
|
|||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Plus,
|
Save,
|
||||||
Trash2,
|
LayoutTemplate,
|
||||||
GripVertical,
|
|
||||||
FileSignature,
|
FileSignature,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
FileQuestion,
|
FileQuestion,
|
||||||
Save,
|
|
||||||
Copy,
|
|
||||||
LayoutTemplate,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { Textarea } from "~/components/ui/textarea";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -31,29 +26,11 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import type { FormField, FormType } from "~/lib/types/forms";
|
||||||
interface Field {
|
import { FORM_FIELD_TYPES } from "~/lib/types/forms";
|
||||||
id: string;
|
import { FormBuilder } from "~/components/forms/FormBuilder";
|
||||||
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: "✍️" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const formTypes = [
|
const formTypes = [
|
||||||
{ value: "consent", label: "Consent Form", icon: FileSignature, description: "Legal/IRB consent documents" },
|
{ value: "consent", label: "Consent Form", icon: FileSignature, description: "Legal/IRB consent documents" },
|
||||||
@@ -65,14 +42,13 @@ export default function NewFormPage() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
const studyId = typeof params.id === "string" ? params.id : "";
|
const studyId = typeof params.id === "string" ? params.id : "";
|
||||||
|
|
||||||
const [formType, setFormType] = useState<string>("");
|
const [formType, setFormType] = useState<string>("");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [fields, setFields] = useState<Field[]>([]);
|
const [fields, setFields] = useState<FormField[]>([]);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const { data: study } = api.studies.get.useQuery(
|
const { data: study } = api.studies.get.useQuery(
|
||||||
@@ -115,25 +91,6 @@ export default function NewFormPage() {
|
|||||||
return notFound();
|
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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -145,7 +102,7 @@ export default function NewFormPage() {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
createForm.mutate({
|
createForm.mutate({
|
||||||
studyId,
|
studyId,
|
||||||
type: formType as "consent" | "survey" | "questionnaire",
|
type: formType as FormType,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
fields,
|
fields,
|
||||||
@@ -266,12 +223,21 @@ export default function NewFormPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Form Fields</CardTitle>
|
<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]">
|
<SelectTrigger className="w-[200px]">
|
||||||
<SelectValue placeholder="Add field..." />
|
<SelectValue placeholder="Add field..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{fieldTypes.map((type) => (
|
{FORM_FIELD_TYPES.map((type) => (
|
||||||
<SelectItem key={type.value} value={type.value}>
|
<SelectItem key={type.value} value={type.value}>
|
||||||
<span className="mr-2">{type.icon}</span>
|
<span className="mr-2">{type.icon}</span>
|
||||||
{type.label}
|
{type.label}
|
||||||
@@ -281,117 +247,7 @@ export default function NewFormPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{fields.length === 0 ? (
|
<FormBuilder fields={fields} onFieldsChange={setFields} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -407,4 +263,4 @@ export default function NewFormPage() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
@@ -22,18 +22,10 @@ import {
|
|||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { Textarea } from "~/components/ui/textarea";
|
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import type { FormField } from "~/lib/types/forms";
|
||||||
interface Field {
|
import { FormFieldRenderer } from "~/components/forms/FormFieldRenderer";
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
label: string;
|
|
||||||
required: boolean;
|
|
||||||
options?: string[];
|
|
||||||
settings?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formTypeIcons = {
|
const formTypeIcons = {
|
||||||
consent: FileSignature,
|
consent: FileSignature,
|
||||||
@@ -47,7 +39,7 @@ export default function ParticipantFormPage() {
|
|||||||
const formId = params.formId as string;
|
const formId = params.formId as string;
|
||||||
|
|
||||||
const [participantCode, setParticipantCode] = useState("");
|
const [participantCode, setParticipantCode] = useState("");
|
||||||
const [formResponses, setFormResponses] = useState<Record<string, any>>({});
|
const [formResponses, setFormResponses] = useState<Record<string, unknown>>({});
|
||||||
const [hasSubmitted, setHasSubmitted] = useState(false);
|
const [hasSubmitted, setHasSubmitted] = useState(false);
|
||||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
@@ -113,7 +105,7 @@ export default function ParticipantFormPage() {
|
|||||||
|
|
||||||
const TypeIcon =
|
const TypeIcon =
|
||||||
formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText;
|
formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText;
|
||||||
const fields = (form.fields as Field[]) || [];
|
const fields = (form.fields as FormField[]) || [];
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const errors: Record<string, string> = {};
|
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 });
|
setFormResponses({ ...formResponses, [fieldId]: value });
|
||||||
if (fieldErrors[fieldId]) {
|
if (fieldErrors[fieldId]) {
|
||||||
const newErrors = { ...fieldErrors };
|
const newErrors = { ...fieldErrors };
|
||||||
@@ -217,175 +209,21 @@ export default function ParticipantFormPage() {
|
|||||||
<div className="border-t pt-6">
|
<div className="border-t pt-6">
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => (
|
||||||
<div key={field.id} className="mb-6 last:mb-0">
|
<div key={field.id} className="mb-6 last:mb-0">
|
||||||
<Label
|
<FormFieldLabel
|
||||||
htmlFor={field.id}
|
field={field}
|
||||||
className={
|
index={index}
|
||||||
fieldErrors[field.id] ? "text-destructive" : ""
|
showIndex
|
||||||
}
|
/>
|
||||||
>
|
|
||||||
{index + 1}. {field.label}
|
|
||||||
{field.required && (
|
|
||||||
<span className="text-destructive"> *</span>
|
|
||||||
)}
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
{field.type === "text" && (
|
<FormFieldRenderer
|
||||||
<Input
|
field={field}
|
||||||
id={field.id}
|
value={formResponses[field.id]}
|
||||||
value={formResponses[field.id] || ""}
|
onChange={(val) => updateResponse(field.id, val)}
|
||||||
onChange={(e) =>
|
mode="participant"
|
||||||
updateResponse(field.id, e.target.value)
|
index={index}
|
||||||
}
|
error={fieldErrors[field.id]}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fieldErrors[field.id] && (
|
{fieldErrors[field.id] && (
|
||||||
@@ -428,3 +266,25 @@ export default function ParticipantFormPage() {
|
|||||||
</div>
|
</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"
|
id="not-robot"
|
||||||
checked={notRobot}
|
checked={notRobot}
|
||||||
onCheckedChange={(checked) => setNotRobot(checked === true)}
|
onCheckedChange={(checked) => setNotRobot(checked === true)}
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor="not-robot"
|
htmlFor="not-robot"
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "~/components/ui/dropdown-menu";
|
} from "~/components/ui/dropdown-menu";
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
|
|
||||||
// Define error type for mutations
|
// Define error type for mutations
|
||||||
interface TRPCError {
|
interface TRPCError {
|
||||||
@@ -101,131 +100,37 @@ const syncStatusConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function RepositoryActionsCell({ repository }: { repository: Repository }) {
|
function RepositoryUrlCell({ url }: { url: string }) {
|
||||||
const utils = api.useUtils();
|
const handleCopy = () => {
|
||||||
|
void navigator.clipboard.writeText(url);
|
||||||
const syncMutation = api.admin.repositories.sync.useMutation({
|
toast.success("URL copied to clipboard");
|
||||||
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 });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<DropdownMenu>
|
<div className="flex items-center gap-2">
|
||||||
<DropdownMenuTrigger asChild>
|
<Button variant="ghost" size="icon" onClick={handleCopy}>
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
<Copy className="h-4 w-4" />
|
||||||
<span className="sr-only">Open menu</span>
|
</Button>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
{url && (
|
||||||
</Button>
|
<Link
|
||||||
</DropdownMenuTrigger>
|
href={url}
|
||||||
<DropdownMenuContent align="end">
|
target="_blank"
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
rel="noopener noreferrer"
|
||||||
<DropdownMenuSeparator />
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={handleSync}
|
|
||||||
disabled={syncMutation.isPending}
|
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<ExternalLink className="mr-1 h-3 w-3" />
|
||||||
className={`mr-2 h-4 w-4 ${syncMutation.isPending ? "animate-spin" : ""}`}
|
Visit
|
||||||
/>
|
</Link>
|
||||||
Sync Repository
|
)}
|
||||||
</DropdownMenuItem>
|
</div>
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const repositoriesColumns: ColumnDef<Repository>[] = [
|
export const repositoriesColumns: ColumnDef<Repository>[] = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
header: ({ table }) => (
|
header: ({ column }) => (
|
||||||
<Checkbox
|
<DataTableColumnHeader column={column} title="#" />
|
||||||
checked={
|
|
||||||
table.getIsAllPageRowsSelected() ||
|
|
||||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
|
||||||
}
|
|
||||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
|
||||||
aria-label="Select all"
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -240,34 +145,16 @@ export const repositoriesColumns: ColumnDef<Repository>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title="Repository Name" />
|
<DataTableColumnHeader column={column} title="Repository" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const repository = row.original;
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-[200px] min-w-0 space-y-1">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center space-x-2">
|
<span className="font-medium">{row.original.name}</span>
|
||||||
<Database className="text-muted-foreground h-4 w-4 flex-shrink-0" />
|
{row.original.description && (
|
||||||
<Link
|
<span className="text-muted-foreground text-xs">
|
||||||
href={`/admin/repositories/${repository.id}`}
|
{row.original.description}
|
||||||
className="truncate font-medium hover:underline"
|
</span>
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -276,22 +163,11 @@ export const repositoriesColumns: ColumnDef<Repository>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: "url",
|
accessorKey: "url",
|
||||||
header: ({ column }) => (
|
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",
|
accessorKey: "trustLevel",
|
||||||
@@ -327,25 +203,15 @@ export const repositoriesColumns: ColumnDef<Repository>[] = [
|
|||||||
const isEnabled = row.original.isEnabled;
|
const isEnabled = row.original.isEnabled;
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant={isEnabled ? "default" : "secondary"}
|
||||||
className={
|
className={isEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"}
|
||||||
isEnabled
|
|
||||||
? "bg-green-100 text-green-800"
|
|
||||||
: "bg-red-100 text-red-800"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isEnabled ? (
|
|
||||||
<CheckCircle className="mr-1 h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="mr-1 h-3 w-3" />
|
|
||||||
)}
|
|
||||||
{isEnabled ? "Enabled" : "Disabled"}
|
{isEnabled ? "Enabled" : "Disabled"}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
filterFn: (row, id, value: string[]) => {
|
filterFn: (row, id, value: string[]) => {
|
||||||
const isEnabled = row.original.isEnabled;
|
return value.includes(row.original.isEnabled ? "enabled" : "disabled");
|
||||||
return value.includes(isEnabled ? "enabled" : "disabled");
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -354,80 +220,97 @@ export const repositoriesColumns: ColumnDef<Repository>[] = [
|
|||||||
<DataTableColumnHeader column={column} title="Sync Status" />
|
<DataTableColumnHeader column={column} title="Sync Status" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const syncStatus = row.original.syncStatus;
|
const status = row.original.syncStatus || "pending";
|
||||||
const lastSyncAt = row.original.lastSyncAt;
|
const config = syncStatusConfig[status as keyof typeof syncStatusConfig];
|
||||||
const syncError = row.original.syncError;
|
const StatusIcon = config?.icon ?? Clock;
|
||||||
|
|
||||||
if (!syncStatus) return "-";
|
|
||||||
|
|
||||||
const config =
|
|
||||||
syncStatusConfig[syncStatus as keyof typeof syncStatusConfig];
|
|
||||||
if (!config) return syncStatus;
|
|
||||||
|
|
||||||
const SyncIcon = config.icon;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="flex items-center gap-2">
|
||||||
<Badge
|
{config && (
|
||||||
variant="secondary"
|
<Badge variant="secondary" className={config.className}>
|
||||||
className={config.className}
|
<StatusIcon className="mr-1 h-3 w-3" />
|
||||||
title={config.description}
|
{config.label}
|
||||||
>
|
</Badge>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
{syncError && syncStatus === "failed" && (
|
{row.original.syncError && (
|
||||||
<div
|
<Button
|
||||||
className="max-w-[150px] truncate text-xs text-red-600"
|
variant="ghost"
|
||||||
title={syncError}
|
size="sm"
|
||||||
|
className="h-auto whitespace-normal text-xs text-destructive"
|
||||||
|
title={row.original.syncError}
|
||||||
>
|
>
|
||||||
{syncError}
|
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||||
</div>
|
Error
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
filterFn: (row, id, value: string[]) => {
|
||||||
{
|
const status = row.original.syncStatus || "pending";
|
||||||
accessorKey: "createdAt",
|
return value.includes(status);
|
||||||
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>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "updatedAt",
|
accessorKey: "lastSyncAt",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title="Updated" />
|
<DataTableColumnHeader column={column} title="Last Sync" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const date = row.original.updatedAt;
|
const lastSync = row.original.lastSyncAt;
|
||||||
return (
|
return (
|
||||||
<div className="text-sm whitespace-nowrap">
|
<span className="text-muted-foreground text-sm">
|
||||||
{formatDistanceToNow(date, { addSuffix: true })}
|
{lastSync ? formatDistanceToNow(lastSync, { addSuffix: true }) : "Never"}
|
||||||
</div>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
sortingFn: "datetime",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "Actions",
|
cell: ({ row }) => {
|
||||||
cell: ({ row }) => <RepositoryActionsCell repository={row.original} />,
|
const repository = row.original;
|
||||||
enableSorting: false,
|
return (
|
||||||
enableHiding: false,
|
<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 { Badge } from "~/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
|
import { formatBytes } from "~/lib/utils";
|
||||||
|
|
||||||
export function SystemStats() {
|
export function SystemStats() {
|
||||||
const { data: stats, isLoading } = api.admin.getSystemStats.useQuery({});
|
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 formatUptime = (seconds: number) => {
|
||||||
const d = Math.floor(seconds / (3600 * 24));
|
const d = Math.floor(seconds / (3600 * 24));
|
||||||
const h = Math.floor((seconds % (3600 * 24)) / 3600);
|
const h = Math.floor((seconds % (3600 * 24)) / 3600);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
UserCheck,
|
UserCheck,
|
||||||
Users,
|
Users,
|
||||||
FileText,
|
FileText,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useSidebar } from "~/components/ui/sidebar";
|
import { useSidebar } from "~/components/ui/sidebar";
|
||||||
@@ -156,7 +157,7 @@ export function AppSidebar({
|
|||||||
isLoadingUserStudies,
|
isLoadingUserStudies,
|
||||||
} = useStudyManagement();
|
} = 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
|
// Reference to track if we've already attempted auto-selection to avoid fighting with manual clearing
|
||||||
const hasAutoSelected = useRef(false);
|
const hasAutoSelected = useRef(false);
|
||||||
@@ -308,12 +309,21 @@ export function AppSidebar({
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
{isTourActive && !isCollapsed && (
|
{isTourActive && !isCollapsed && (
|
||||||
<div className="mt-1 px-3 pb-2">
|
<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">
|
<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">
|
||||||
<span className="relative flex h-2 w-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
|
<span className="relative flex h-2 w-2">
|
||||||
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full"></span>
|
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
|
||||||
</span>
|
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full"></span>
|
||||||
Tutorial Active
|
</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>
|
||||||
</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 {
|
interface TourContextType {
|
||||||
startTour: (tour: TourType) => void;
|
startTour: (tour: TourType) => void;
|
||||||
|
stopTour: () => void;
|
||||||
isTourActive: boolean;
|
isTourActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,7 +355,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
onDestroyed: () => {
|
onDestroyed: () => {
|
||||||
// Persistence handled by localStorage state
|
localStorage.removeItem("hristudio_tour_mode");
|
||||||
|
Cookies.remove("hristudio_tour_mode");
|
||||||
setIsTourActive(false);
|
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 (
|
return (
|
||||||
<TourContext.Provider value={{ startTour, isTourActive }}>
|
<TourContext.Provider value={{ startTour, stopTour, isTourActive }}>
|
||||||
{children}
|
{children}
|
||||||
<style jsx global>{`
|
<style jsx global>{`
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Badge } from "~/components/ui/badge";
|
|||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Checkbox } from "~/components/ui/checkbox";
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
||||||
|
import { trustLevelConfig } from "~/lib/constants";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
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 = {
|
const statusConfig = {
|
||||||
active: {
|
active: {
|
||||||
label: "Active",
|
label: "Active",
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ const trialSchema = z.object({
|
|||||||
experimentId: z.string().uuid("Please select an experiment"),
|
experimentId: z.string().uuid("Please select an experiment"),
|
||||||
participantId: z.string().uuid("Please select a participant"),
|
participantId: z.string().uuid("Please select a participant"),
|
||||||
scheduledAt: z.date(),
|
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(),
|
notes: z.string().max(1000, "Notes cannot exceed 1000 characters").optional(),
|
||||||
sessionNumber: z
|
sessionNumber: z
|
||||||
.number()
|
.number()
|
||||||
@@ -269,8 +269,18 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
}
|
}
|
||||||
}, [trial, mode, form]);
|
}, [trial, mode, form]);
|
||||||
|
|
||||||
const createTrialMutation = api.trials.create.useMutation();
|
const createTrialMutation = api.trials.create.useMutation({
|
||||||
const updateTrialMutation = api.trials.update.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
|
// Form submission
|
||||||
const onSubmit = async (data: TrialFormData) => {
|
const onSubmit = async (data: TrialFormData) => {
|
||||||
@@ -283,7 +293,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
experimentId: data.experimentId,
|
experimentId: data.experimentId,
|
||||||
participantId: data.participantId,
|
participantId: data.participantId,
|
||||||
scheduledAt: data.scheduledAt,
|
scheduledAt: data.scheduledAt,
|
||||||
wizardId: data.wizardId,
|
wizardId: data.wizardId || undefined,
|
||||||
sessionNumber: data.sessionNumber ?? 1,
|
sessionNumber: data.sessionNumber ?? 1,
|
||||||
notes: data.notes ?? undefined,
|
notes: data.notes ?? undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -506,6 +506,10 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
setTrial({ ...trial, status: data.status, startedAt: data.startedAt });
|
setTrial({ ...trial, status: data.status, startedAt: data.startedAt });
|
||||||
setTrialStartTime(new Date());
|
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({
|
const completeTrialMutation = api.trials.complete.useMutation({
|
||||||
@@ -528,6 +532,10 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setTrial({ ...trial, status: data.status });
|
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({
|
const pauseTrialMutation = api.trials.pause.useMutation({
|
||||||
@@ -1306,8 +1314,6 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
onStepSelect={handleStepSelect}
|
onStepSelect={handleStepSelect}
|
||||||
onExecuteAction={handleExecuteAction}
|
onExecuteAction={handleExecuteAction}
|
||||||
onExecuteRobotAction={handleExecuteRobotAction}
|
onExecuteRobotAction={handleExecuteRobotAction}
|
||||||
activeTab={executionPanelTab}
|
|
||||||
onTabChange={setExecutionPanelTab}
|
|
||||||
onSkipAction={handleSkipAction}
|
onSkipAction={handleSkipAction}
|
||||||
isExecuting={isExecutingAction}
|
isExecuting={isExecutingAction}
|
||||||
onNextStep={handleNextStep}
|
onNextStep={handleNextStep}
|
||||||
|
|||||||
@@ -3,85 +3,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { WizardActionItem } from "./WizardActionItem";
|
import { WizardActionItem } from "./WizardActionItem";
|
||||||
import {
|
import {
|
||||||
Play,
|
|
||||||
SkipForward,
|
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Zap,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
Clock,
|
Clock,
|
||||||
RotateCcw,
|
|
||||||
AlertTriangle,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import type { TrialData, StepData, TrialEvent } from "~/lib/types/trial";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WizardExecutionPanelProps {
|
interface WizardExecutionPanelProps {
|
||||||
trial: TrialData;
|
trial: TrialData;
|
||||||
@@ -100,8 +29,6 @@ interface WizardExecutionPanelProps {
|
|||||||
parameters: Record<string, unknown>,
|
parameters: Record<string, unknown>,
|
||||||
options?: { autoAdvance?: boolean },
|
options?: { autoAdvance?: boolean },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
activeTab: "current" | "timeline" | "events"; // Deprecated/Ignored
|
|
||||||
onTabChange: (tab: "current" | "timeline" | "events") => void; // Deprecated/Ignored
|
|
||||||
onSkipAction: (
|
onSkipAction: (
|
||||||
pluginName: string,
|
pluginName: string,
|
||||||
actionId: string,
|
actionId: string,
|
||||||
@@ -118,7 +45,7 @@ interface WizardExecutionPanelProps {
|
|||||||
rosConnected?: boolean;
|
rosConnected?: boolean;
|
||||||
completedStepIndices?: Set<number>;
|
completedStepIndices?: Set<number>;
|
||||||
skippedStepIndices?: Set<number>;
|
skippedStepIndices?: Set<number>;
|
||||||
onLogEvent?: (type: string, data?: any) => void;
|
onLogEvent?: (type: string, data?: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WizardExecutionPanel({
|
export function WizardExecutionPanel({
|
||||||
@@ -130,8 +57,6 @@ export function WizardExecutionPanel({
|
|||||||
onStepSelect,
|
onStepSelect,
|
||||||
onExecuteAction,
|
onExecuteAction,
|
||||||
onExecuteRobotAction,
|
onExecuteRobotAction,
|
||||||
activeTab,
|
|
||||||
onTabChange,
|
|
||||||
onSkipAction,
|
onSkipAction,
|
||||||
isExecuting = false,
|
isExecuting = false,
|
||||||
onNextStep,
|
onNextStep,
|
||||||
|
|||||||
@@ -153,7 +153,13 @@ export function EntityForm<T extends FieldValues = FieldValues>({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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 */}
|
{/* Form Fields */}
|
||||||
{children}
|
{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 { toast } from "sonner";
|
||||||
import { TRPCClientError } from "@trpc/client";
|
import { TRPCClientError } from "@trpc/client";
|
||||||
|
|
||||||
/**
|
|
||||||
* Auth error codes that should trigger automatic logout
|
|
||||||
*/
|
|
||||||
const AUTH_ERROR_CODES = [
|
const AUTH_ERROR_CODES = [
|
||||||
"UNAUTHORIZED",
|
"UNAUTHORIZED",
|
||||||
"FORBIDDEN",
|
"FORBIDDEN",
|
||||||
"UNAUTHENTICATED",
|
"UNAUTHENTICATED",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Auth error messages that should trigger automatic logout
|
|
||||||
*/
|
|
||||||
const AUTH_ERROR_MESSAGES = [
|
const AUTH_ERROR_MESSAGES = [
|
||||||
"unauthorized",
|
"unauthorized",
|
||||||
"unauthenticated",
|
"unauthenticated",
|
||||||
@@ -27,15 +21,10 @@ const AUTH_ERROR_MESSAGES = [
|
|||||||
"access denied",
|
"access denied",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if an error is an authentication/authorization error that should trigger logout
|
|
||||||
*/
|
|
||||||
export function isAuthError(error: unknown): boolean {
|
export function isAuthError(error: unknown): boolean {
|
||||||
if (!error) return false;
|
if (!error) return false;
|
||||||
|
|
||||||
// Check TRPC errors
|
|
||||||
if (error instanceof TRPCClientError) {
|
if (error instanceof TRPCClientError) {
|
||||||
// Check error code
|
|
||||||
const trpcErrorData = error.data as
|
const trpcErrorData = error.data as
|
||||||
| { code?: string; httpStatus?: number }
|
| { code?: string; httpStatus?: number }
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -47,24 +36,20 @@ export function isAuthError(error: unknown): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check HTTP status codes
|
|
||||||
const httpStatus = trpcErrorData?.httpStatus;
|
const httpStatus = trpcErrorData?.httpStatus;
|
||||||
if (httpStatus === 401 || httpStatus === 403) {
|
if (httpStatus === 401 || httpStatus === 403) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check error message
|
|
||||||
const message = error.message?.toLowerCase() ?? "";
|
const message = error.message?.toLowerCase() ?? "";
|
||||||
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check generic errors
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
const message = error.message?.toLowerCase() || "";
|
const message = error.message?.toLowerCase() || "";
|
||||||
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check error objects with message property
|
|
||||||
if (typeof error === "object" && error !== null) {
|
if (typeof error === "object" && error !== null) {
|
||||||
if ("message" in error) {
|
if ("message" in error) {
|
||||||
const errorObj = error as { message: unknown };
|
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));
|
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for status codes in error objects
|
|
||||||
if ("status" in error) {
|
if ("status" in error) {
|
||||||
const statusObj = error as { status: unknown };
|
const statusObj = error as { status: unknown };
|
||||||
const status = statusObj.status as number;
|
const status = statusObj.status as number;
|
||||||
@@ -83,9 +67,6 @@ export function isAuthError(error: unknown): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles authentication errors by logging out the user
|
|
||||||
*/
|
|
||||||
export async function handleAuthError(
|
export async function handleAuthError(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
customMessage?: string,
|
customMessage?: string,
|
||||||
@@ -96,11 +77,9 @@ export async function handleAuthError(
|
|||||||
|
|
||||||
console.warn("Authentication error detected, logging out user:", error);
|
console.warn("Authentication error detected, logging out user:", error);
|
||||||
|
|
||||||
// Show user-friendly message
|
|
||||||
const message = customMessage ?? "Session expired. Please log in again.";
|
const message = customMessage ?? "Session expired. Please log in again.";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
|
|
||||||
// Small delay to let the toast show
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
@@ -108,72 +87,19 @@ export async function handleAuthError(
|
|||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
} catch (signOutError) {
|
} catch (signOutError) {
|
||||||
console.error("Error during sign out:", signOutError);
|
console.error("Error during sign out:", signOutError);
|
||||||
// Force redirect if signOut fails
|
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, 1000);
|
}, 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() {
|
export function useAuthErrorHandler() {
|
||||||
return {
|
return {
|
||||||
handleAuthError: (error: unknown, customMessage?: string) => {
|
handleAuthError,
|
||||||
void handleAuthError(error, customMessage);
|
|
||||||
},
|
|
||||||
isAuthError,
|
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 {
|
export function shouldShowGenericError(error: unknown): boolean {
|
||||||
return !isAuthError(error);
|
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
|
* Subscribe to robot sensor topics
|
||||||
*/
|
*/
|
||||||
private subscribeToRobotTopics(): void {
|
private subscribeToRobotTopics(): void {
|
||||||
|
console.log("[WizardROS] Setting up robot topics...");
|
||||||
const topics = [
|
const topics = [
|
||||||
{ topic: "/joint_states", type: "sensor_msgs/JointState" },
|
{ topic: "/joint_states", type: "sensor_msgs/JointState" },
|
||||||
{ topic: "/bumper", type: "naoqi_bridge_msgs/Bumper" },
|
{ topic: "/bumper", type: "naoqi_bridge_msgs/Bumper" },
|
||||||
@@ -476,6 +477,11 @@ export class WizardRosService extends EventEmitter {
|
|||||||
topics.forEach(({ topic, type }) => {
|
topics.forEach(({ topic, type }) => {
|
||||||
this.subscribe(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);
|
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
|
* 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