diff --git a/drizzle/migrations/0004_sturdy_natasha_romanoff.sql b/drizzle/migrations/0004_sturdy_natasha_romanoff.sql new file mode 100644 index 0000000..629c5fe --- /dev/null +++ b/drizzle/migrations/0004_sturdy_natasha_romanoff.sql @@ -0,0 +1,22 @@ +CREATE TABLE "dashboard_widgets" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "dashboard_id" uuid NOT NULL, + "view_id" uuid NOT NULL, + "title" text NOT NULL, + "widget_type" text NOT NULL, + "position" jsonb DEFAULT '{"x":0,"y":0,"w":4,"h":2}', + "config" jsonb DEFAULT '{}', + "created_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE "dashboards" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "description" text, + "layout" jsonb DEFAULT '[]', + "is_default" boolean DEFAULT false, + "created_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "dashboard_widgets" ADD CONSTRAINT "dashboard_widgets_dashboard_id_dashboards_id_fk" FOREIGN KEY ("dashboard_id") REFERENCES "public"."dashboards"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "dashboard_widgets" ADD CONSTRAINT "dashboard_widgets_view_id_views_id_fk" FOREIGN KEY ("view_id") REFERENCES "public"."views"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/migrations/meta/0004_snapshot.json b/drizzle/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..609c00e --- /dev/null +++ b/drizzle/migrations/meta/0004_snapshot.json @@ -0,0 +1,1156 @@ +{ + "id": "c5dd1071-68a3-4180-b846-9c7cdd3c1af0", + "prevId": "6c5e0b0c-45b7-4ff0-9979-9fc22f187b0f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.custom_field_values": { + "name": "custom_field_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "custom_field_id": { + "name": "custom_field_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "custom_field_values_ticket_id_idx": { + "name": "custom_field_values_ticket_id_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_field_values_custom_field_id_idx": { + "name": "custom_field_values_custom_field_id_idx", + "columns": [ + { + "expression": "custom_field_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_field_values_custom_field_id_custom_fields_id_fk": { + "name": "custom_field_values_custom_field_id_custom_fields_id_fk", + "tableFrom": "custom_field_values", + "tableTo": "custom_fields", + "columnsFrom": [ + "custom_field_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_field_values_ticket_id_tickets_id_fk": { + "name": "custom_field_values_ticket_id_tickets_id_fk", + "tableFrom": "custom_field_values", + "tableTo": "tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custom_field_values_cf_id_ticket_id_value_unique": { + "name": "custom_field_values_cf_id_ticket_id_value_unique", + "nullsNotDistinct": false, + "columns": [ + "custom_field_id", + "ticket_id", + "value" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_fields": { + "name": "custom_fields", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "values": { + "name": "values", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_values": { + "name": "max_values", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custom_fields_key_unique": { + "name": "custom_fields_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_widgets": { + "name": "dashboard_widgets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "dashboard_id": { + "name": "dashboard_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "view_id": { + "name": "view_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "widget_type": { + "name": "widget_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"x\":0,\"y\":0,\"w\":4,\"h\":2}'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_widgets_dashboard_id_dashboards_id_fk": { + "name": "dashboard_widgets_dashboard_id_dashboards_id_fk", + "tableFrom": "dashboard_widgets", + "tableTo": "dashboards", + "columnsFrom": [ + "dashboard_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dashboard_widgets_view_id_views_id_fk": { + "name": "dashboard_widgets_view_id_views_id_fk", + "tableFrom": "dashboard_widgets", + "tableTo": "views", + "columnsFrom": [ + "view_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboards": { + "name": "dashboards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lifecycles": { + "name": "lifecycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "definition": { + "name": "definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "lifecycles_name_unique": { + "name": "lifecycles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_custom_fields": { + "name": "queue_custom_fields", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "custom_field_id": { + "name": "custom_field_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "queue_custom_fields_queue_id_queues_id_fk": { + "name": "queue_custom_fields_queue_id_queues_id_fk", + "tableFrom": "queue_custom_fields", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "queue_custom_fields_custom_field_id_custom_fields_id_fk": { + "name": "queue_custom_fields_custom_field_id_custom_fields_id_fk", + "tableFrom": "queue_custom_fields", + "tableTo": "custom_fields", + "columnsFrom": [ + "custom_field_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "queue_custom_fields_queue_id_custom_field_id_unique": { + "name": "queue_custom_fields_queue_id_custom_field_id_unique", + "nullsNotDistinct": false, + "columns": [ + "queue_id", + "custom_field_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queues": { + "name": "queues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle_id": { + "name": "lifecycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "queues_lifecycle_id_lifecycles_id_fk": { + "name": "queues_lifecycle_id_lifecycles_id_fk", + "tableFrom": "queues", + "tableTo": "lifecycles", + "columnsFrom": [ + "lifecycle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "queues_name_unique": { + "name": "queues_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scrips": { + "name": "scrips", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "condition_type": { + "name": "condition_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "condition_config": { + "name": "condition_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_config": { + "name": "action_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "stage": { + "name": "stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'TransactionCreate'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "scrips_queue_id_idx": { + "name": "scrips_queue_id_idx", + "columns": [ + { + "expression": "queue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scrips_queue_id_queues_id_fk": { + "name": "scrips_queue_id_queues_id_fk", + "tableFrom": "scrips", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "scrips_template_id_templates_id_fk": { + "name": "scrips_template_id_templates_id_fk", + "tableFrom": "scrips", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_template": { + "name": "subject_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_template": { + "name": "body_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "templates_queue_id_queues_id_fk": { + "name": "templates_queue_id_queues_id_fk", + "tableFrom": "templates", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "tickets_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tickets_queue_id_idx": { + "name": "tickets_queue_id_idx", + "columns": [ + { + "expression": "queue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tickets_status_idx": { + "name": "tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tickets_queue_id_queues_id_fk": { + "name": "tickets_queue_id_queues_id_fk", + "tableFrom": "tickets", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tickets_owner_id_users_id_fk": { + "name": "tickets_owner_id_users_id_fk", + "tableFrom": "tickets", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tickets_creator_id_users_id_fk": { + "name": "tickets_creator_id_users_id_fk", + "tableFrom": "tickets", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transaction_type": { + "name": "transaction_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field": { + "name": "field", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "transactions_ticket_id_idx": { + "name": "transactions_ticket_id_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactions_created_at_idx": { + "name": "transactions_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactions_ticket_id_tickets_id_fk": { + "name": "transactions_ticket_id_tickets_id_fk", + "tableFrom": "transactions", + "tableTo": "tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transactions_creator_id_users_id_fk": { + "name": "transactions_creator_id_users_id_fk", + "tableFrom": "transactions", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.views": { + "name": "views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filters": { + "name": "filters", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'updated'" + }, + "columns": { + "name": "columns", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "views_creator_id_users_id_fk": { + "name": "views_creator_id_users_id_fk", + "tableFrom": "views", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 7f2ca27..5f0d506 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1780995910694, "tag": "0003_dry_caretaker", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1780996807814, + "tag": "0004_sturdy_natasha_romanoff", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index ad6b6d1..84ff23c 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -123,3 +123,23 @@ export const views = pgTable('views', { creator_id: uuid('creator_id').references(() => users.id), created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), }); + +export const dashboards = pgTable('dashboards', { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + description: text('description'), + layout: jsonb('layout').default('[]'), + is_default: boolean('is_default').default(false), + created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), +}); + +export const dashboardWidgets = pgTable('dashboard_widgets', { + id: uuid('id').primaryKey().defaultRandom(), + dashboard_id: uuid('dashboard_id').notNull().references(() => dashboards.id, { onDelete: 'cascade' }), + view_id: uuid('view_id').notNull().references(() => views.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + widget_type: text('widget_type').notNull(), + position: jsonb('position').default('{"x":0,"y":0,"w":4,"h":2}'), + config: jsonb('config').default('{}'), + created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), +}); diff --git a/src/index.ts b/src/index.ts index 3489a94..b371e2d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { createLifecyclesRouter } from './routes/lifecycles.ts'; import { createUsersRouter } from './routes/users.ts'; import { createTemplatesRouter } from './routes/templates.ts'; import { createViewsRouter } from './routes/views.ts'; +import { createDashboardsRouter } from './routes/dashboards.ts'; let db: Db | null = null; @@ -37,6 +38,7 @@ app.route('/lifecycles', createLifecyclesRouter(getDb())); app.route('/users', createUsersRouter(getDb())); app.route('/templates', createTemplatesRouter(getDb())); app.route('/views', createViewsRouter(getDb())); +app.route('/dashboards', createDashboardsRouter(getDb())); export default app; export { app }; diff --git a/src/routes/dashboards.ts b/src/routes/dashboards.ts new file mode 100644 index 0000000..ef3aa38 --- /dev/null +++ b/src/routes/dashboards.ts @@ -0,0 +1,385 @@ +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { asc, eq } from 'drizzle-orm'; +import type { Db } from '../db/index.ts'; +import { + dashboards, + dashboardWidgets, + tickets, + customFieldValues, + customFields, + lifecycles, + queues, + views, +} from '../db/schema.ts'; + +function statusClass(def: { statuses: { initial: string[]; active: string[]; inactive: string[] } }, status: string): string { + if (def.statuses.initial.includes(status)) return 'initial'; + if (def.statuses.active.includes(status)) return 'active'; + if (def.statuses.inactive.includes(status)) return 'inactive'; + return 'unknown'; +} + +export function createDashboardsRouter(db: Db): Hono { + const router = new Hono(); + + // ── Dashboards CRUD ── + + router.get('/', async (c) => { + const result = await db.query.dashboards.findMany({ + orderBy: asc(dashboards.name), + }); + return c.json(result); + }); + + router.post('/', async (c) => { + const body = await c.req.json(); + const name = String(body.name ?? '').trim(); + if (!name) { + throw new HTTPException(400, { message: 'name is required' }); + } + + const [dashboard] = await db.insert(dashboards).values({ + name, + description: body.description ?? null, + layout: body.layout ?? [], + is_default: body.is_default ?? false, + }).returning(); + + if (!dashboard) { + throw new HTTPException(500, { message: 'Failed to create dashboard' }); + } + + return c.json(dashboard, 201); + }); + + router.get('/:id', async (c) => { + const id = c.req.param('id'); + const dashboard = await db.query.dashboards.findFirst({ + where: eq(dashboards.id, id), + }); + + if (!dashboard) { + throw new HTTPException(404, { message: 'Dashboard not found' }); + } + + const widgets = await db.query.dashboardWidgets.findMany({ + where: eq(dashboardWidgets.dashboard_id, id), + orderBy: asc(dashboardWidgets.created_at), + }); + + return c.json({ ...dashboard, widgets }); + }); + + router.patch('/:id', async (c) => { + const id = c.req.param('id'); + const body = await c.req.json(); + + const existing = await db.query.dashboards.findFirst({ + where: eq(dashboards.id, id), + }); + + if (!existing) { + throw new HTTPException(404, { message: 'Dashboard not found' }); + } + + const updateData: Partial = {}; + if (body.name !== undefined) updateData.name = String(body.name).trim(); + if (body.description !== undefined) updateData.description = body.description ?? null; + if (body.layout !== undefined) updateData.layout = body.layout; + if (body.is_default !== undefined) { + updateData.is_default = body.is_default; + // If setting this as default, unset others + if (body.is_default) { + await db.update(dashboards) + .set({ is_default: false }) + .where(eq(dashboards.is_default, true)); + } + } + + const [updated] = await db.update(dashboards) + .set(updateData) + .where(eq(dashboards.id, id)) + .returning(); + + return c.json(updated); + }); + + router.delete('/:id', async (c) => { + const id = c.req.param('id'); + const existing = await db.query.dashboards.findFirst({ + where: eq(dashboards.id, id), + }); + + if (!existing) { + throw new HTTPException(404, { message: 'Dashboard not found' }); + } + + await db.delete(dashboards).where(eq(dashboards.id, id)); + return c.json({ ok: true }); + }); + + // ── Widgets CRUD ── + + router.get('/:id/widgets', async (c) => { + const dashboardId = c.req.param('id'); + const result = await db.query.dashboardWidgets.findMany({ + where: eq(dashboardWidgets.dashboard_id, dashboardId), + orderBy: asc(dashboardWidgets.created_at), + }); + return c.json(result); + }); + + router.post('/:id/widgets', async (c) => { + const dashboardId = c.req.param('id'); + const dashboard = await db.query.dashboards.findFirst({ + where: eq(dashboards.id, dashboardId), + }); + + if (!dashboard) { + throw new HTTPException(404, { message: 'Dashboard not found' }); + } + + const body = await c.req.json(); + const title = String(body.title ?? 'Widget').trim(); + const widgetType = String(body.widget_type ?? 'count').trim(); + const viewId = String(body.view_id ?? '').trim(); + + if (!viewId) { + throw new HTTPException(400, { message: 'view_id is required' }); + } + + const [widget] = await db.insert(dashboardWidgets).values({ + dashboard_id: dashboardId, + view_id: viewId, + title, + widget_type: widgetType, + position: body.position ?? { x: 0, y: 0, w: 4, h: 2 }, + config: body.config ?? {}, + }).returning(); + + if (!widget) { + throw new HTTPException(500, { message: 'Failed to create widget' }); + } + + return c.json(widget, 201); + }); + + router.patch('/:id/widgets/:widgetId', async (c) => { + const widgetId = c.req.param('widgetId'); + const body = await c.req.json(); + + const existing = await db.query.dashboardWidgets.findFirst({ + where: eq(dashboardWidgets.id, widgetId), + }); + + if (!existing) { + throw new HTTPException(404, { message: 'Widget not found' }); + } + + const updateData: Partial = {}; + if (body.title !== undefined) updateData.title = String(body.title).trim(); + if (body.widget_type !== undefined) updateData.widget_type = String(body.widget_type); + if (body.position !== undefined) updateData.position = body.position; + if (body.config !== undefined) updateData.config = body.config; + + const [updated] = await db.update(dashboardWidgets) + .set(updateData) + .where(eq(dashboardWidgets.id, widgetId)) + .returning(); + + return c.json(updated); + }); + + router.delete('/:id/widgets/:widgetId', async (c) => { + const widgetId = c.req.param('widgetId'); + const existing = await db.query.dashboardWidgets.findFirst({ + where: eq(dashboardWidgets.id, widgetId), + }); + + if (!existing) { + throw new HTTPException(404, { message: 'Widget not found' }); + } + + await db.delete(dashboardWidgets).where(eq(dashboardWidgets.id, widgetId)); + return c.json({ ok: true }); + }); + + // ── Widget data endpoint ── + + router.get('/:id/widgets/:widgetId/data', async (c) => { + const widgetId = c.req.param('widgetId'); + + const widget = await db.query.dashboardWidgets.findFirst({ + where: eq(dashboardWidgets.id, widgetId), + }); + + if (!widget) { + throw new HTTPException(404, { message: 'Widget not found' }); + } + + const view = await db.query.views.findFirst({ + where: eq(views.id, widget.view_id), + }); + + if (!view) { + return c.json({ error: 'View not found' }, 404); + } + + // Apply saved view filters + const savedFilters = (view.filters ?? []) as { field: string; operator: string; value: string }[]; + let result = await db.query.tickets.findMany({ + orderBy: asc(tickets.created_at), + }); + + for (const f of savedFilters) { + if (f.field === 'status') { + result = result.filter((t) => t.status === f.value); + } else if (f.field === 'queue') { + result = result.filter((t) => t.queue_id === f.value); + } else if (f.field === 'owner') { + result = f.value === 'unassigned' + ? result.filter((t) => !t.owner_id) + : result.filter((t) => t.owner_id === f.value); + } else if (f.field.startsWith('cf.')) { + const cfKey = f.field.slice(3); + const ticketIds = result.map((t) => t.id); + if (ticketIds.length > 0) { + const cfValues = await db.query.customFieldValues.findMany({ + where: (table, { and, inArray, eq }) => + and( + inArray(table.ticket_id, ticketIds), + eq(table.value, f.value), + ), + }); + const matchingIds = new Set(cfValues.map((v) => v.ticket_id)); + // Also find the field ID for the key + const cfField = await db.query.customFields.findFirst({ + where: eq(customFields.key, cfKey), + }); + if (cfField) { + const cfValuesForField = await db.query.customFieldValues.findMany({ + where: (table, { and, inArray, eq }) => + and( + inArray(table.ticket_id, ticketIds), + eq(table.custom_field_id, cfField.id), + eq(table.value, f.value), + ), + }); + const matchSet = new Set(cfValuesForField.map((v) => v.ticket_id)); + result = result.filter((t) => matchSet.has(t.id)); + } else { + result = result.filter((t) => matchingIds.has(t.id)); + } + } + } + } + + const limit = (widget.config as Record)?.limit as number ?? 5; + + // Find lifecycle for status classification + const queueIds = [...new Set(result.map((r) => r.queue_id))]; + const queueRecords = queueIds.length > 0 + ? await db.query.queues.findMany({ + where: (table, { inArray }) => inArray(table.id, queueIds), + }) + : []; + const lifecycleIds = [...new Set(queueRecords.map((q) => q.lifecycle_id).filter(Boolean))] as string[]; + const lifecycleRecords = lifecycleIds.length > 0 + ? await db.query.lifecycles.findMany({ + where: (table, { inArray }) => inArray(table.id, lifecycleIds), + }) + : []; + const lifecycleByQueue = new Map(); + for (const qr of queueRecords) { + if (qr.lifecycle_id) { + const lc = lifecycleRecords.find((l) => l.id === qr.lifecycle_id); + if (lc) lifecycleByQueue.set(qr.id, lc.definition as any); + } + } + + // Get owner usernames + const ownerIds = [...new Set(result.map((t) => t.owner_id).filter(Boolean))] as string[]; + const ownerUsers = ownerIds.length > 0 + ? await db.query.users.findMany({ + where: (table, { inArray }) => inArray(table.id, ownerIds), + }) + : []; + const ownerName = new Map(ownerUsers.map((u) => [u.id, u.username])); + + // Get queue names + const queueName = new Map(queueRecords.map((q) => [q.id, q.name])); + + switch (widget.widget_type) { + case 'count': { + return c.json({ type: 'count', total: result.length, title: widget.title, view_id: view.id }); + } + + case 'ticket_list': { + const slice = result.slice(0, limit).map((ticket) => ({ + id: ticket.id, + subject: ticket.subject, + status: ticket.status, + owner_id: ticket.owner_id, + owner_name: ticket.owner_id ? ownerName.get(ticket.owner_id) ?? null : null, + queue_name: queueName.get(ticket.queue_id) ?? ticket.queue_id.slice(0, 8), + updated_at: ticket.updated_at?.toISOString(), + })); + return c.json({ type: 'ticket_list', tickets: slice, total: result.length, title: widget.title, view_id: view.id }); + } + + case 'status_chart': { + const counts: Record = {}; + for (const ticket of result) { + counts[ticket.status] = (counts[ticket.status] ?? 0) + 1; + } + return c.json({ type: 'status_chart', counts, total: result.length, title: widget.title, view_id: view.id }); + } + + case 'grouped_counts': { + const groupBy = (widget.config as Record)?.group_by as string ?? 'owner'; + const groups: Record = {}; + + if (groupBy === 'owner') { + for (const ticket of result) { + const label = ticket.owner_id + ? (ownerName.get(ticket.owner_id) ?? ticket.owner_id.slice(0, 8)) + : 'Unassigned'; + groups[label] = (groups[label] ?? 0) + 1; + } + } else if (groupBy === 'queue') { + for (const ticket of result) { + const label = queueName.get(ticket.queue_id) ?? ticket.queue_id.slice(0, 8); + groups[label] = (groups[label] ?? 0) + 1; + } + } else if (groupBy.startsWith('cf.')) { + const cfKey = groupBy.slice(3); + const cfField = await db.query.customFields.findFirst({ + where: eq(customFields.key, cfKey), + }); + if (cfField) { + const ticketIds = result.map((t) => t.id); + const cfValues = ticketIds.length > 0 + ? await db.query.customFieldValues.findMany({ + where: (table, { and, inArray, eq }) => + and( + inArray(table.ticket_id, ticketIds), + eq(table.custom_field_id, cfField.id), + ), + }) + : []; + for (const v of cfValues) { + groups[v.value] = (groups[v.value] ?? 0) + 1; + } + } + } + return c.json({ type: 'grouped_counts', groups, total: result.length, group_by: groupBy, title: widget.title, view_id: view.id }); + } + + default: + return c.json({ type: 'count', total: result.length, title: widget.title, view_id: view.id }); + } + }); + + return router; +} diff --git a/web/src/app/dashboards/[id]/page.tsx b/web/src/app/dashboards/[id]/page.tsx new file mode 100644 index 0000000..d0044c3 --- /dev/null +++ b/web/src/app/dashboards/[id]/page.tsx @@ -0,0 +1,319 @@ +"use client"; + +import { useState, useEffect, use, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { + PlusIcon, + Trash2Icon, + RefreshCwIcon, + GaugeIcon, + LayoutGridIcon, +} from "lucide-react"; +import { + getDashboard, + createWidget, + deleteWidget, + getWidgetData, + getViews, +} from "@/lib/api"; +import type { + Dashboard, + DashboardWidget, + SavedView, + WidgetData, +} from "@/lib/types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { CountWidget } from "@/components/widgets/count-widget"; +import { TicketListWidget } from "@/components/widgets/ticket-list-widget"; +import { StatusChartWidget } from "@/components/widgets/status-chart-widget"; +import { GroupedCountsWidget } from "@/components/widgets/grouped-counts-widget"; +import { cn } from "@/lib/utils"; + +function widgetGridStyle(position: { x: number; y: number; w: number; h: number }) { + return { + gridColumn: `${position.x + 1} / span ${position.w}`, + gridRow: `${position.y + 1} / span ${position.h}`, + }; +} + +export default function DashboardPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params); + const router = useRouter(); + + const [dashboard, setDashboard] = useState(null); + const [widgets, setWidgets] = useState<(DashboardWidget & { data?: WidgetData })[]>([]); + const [views, setViews] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Add widget dialog + const [addOpen, setAddOpen] = useState(false); + const [addViewId, setAddViewId] = useState(""); + const [addTitle, setAddTitle] = useState(""); + const [addType, setAddType] = useState("count"); + const [addGroupBy, setAddGroupBy] = useState("owner"); + const [adding, setAdding] = useState(false); + + const fetchDashboard = useCallback(async () => { + const { data, error } = await getDashboard(id); + if (error || !data) { + setError(error ?? "Dashboard not found"); + setLoading(false); + return; + } + setDashboard(data); + const widgetList = data.widgets ?? []; + setWidgets(widgetList); + + // Fetch data for each widget + for (const widget of widgetList) { + const { data: wData } = await getWidgetData(id, widget.id); + if (wData) { + setWidgets((prev) => + prev.map((w) => (w.id === widget.id ? { ...w, data: wData } : w)) + ); + } + } + + setLoading(false); + }, [id]); + + useEffect(() => { + fetchDashboard(); + getViews().then(({ data }) => { + if (data) setViews(data); + }); + }, [fetchDashboard]); + + const handleAddWidget = async () => { + if (!addViewId || !addTitle.trim()) return; + setAdding(true); + const pos = { x: 0, y: widgets.length, w: 4, h: 2 }; + const config = addType === "grouped_counts" ? { group_by: addGroupBy } : {}; + const { data, error } = await createWidget(id, { + view_id: addViewId, + title: addTitle.trim(), + widget_type: addType, + position: pos, + config, + }); + if (!error && data) { + setWidgets((prev) => [...prev, data]); + const { data: wData } = await getWidgetData(id, data.id); + if (wData) { + setWidgets((prev) => prev.map((w) => (w.id === data.id ? { ...w, data: wData } : w))); + } + setAddOpen(false); + setAddViewId(""); + setAddTitle(""); + setAddType("count"); + } + setAdding(false); + }; + + const handleDeleteWidget = async (widgetId: string) => { + await deleteWidget(id, widgetId); + setWidgets((prev) => prev.filter((w) => w.id !== widgetId)); + }; + + const renderWidget = (widget: DashboardWidget & { data?: WidgetData }) => { + if (!widget.data) { + return ( +
+
+
+ ); + } + + switch (widget.data.type) { + case "count": + return ; + case "ticket_list": + return ; + case "status_chart": + return ; + case "grouped_counts": + return ; + default: + return ( +
+

Unknown type: {widget.data.type}

+
+ ); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error || !dashboard) { + return ( +
+

{error ?? "Dashboard not found"}

+ + Go to ticket list + +
+ ); + } + + return ( +
+
+
+
+
+ + Dashboard +
+

{dashboard.name}

+ {dashboard.description && ( +

{dashboard.description}

+ )} +
+
+ + +
+
+
+ +
+ {widgets.length === 0 ? ( +
+ +

No widgets yet

+ +
+ ) : ( +
+ {widgets.map((widget) => ( +
+ {renderWidget(widget)} + +
+ ))} +
+ )} +
+ + + + + Add widget + + Choose a saved view and widget type to add to this dashboard. + + +
+
+ + setAddTitle(e.target.value)} + placeholder="e.g. Open tickets" + className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring" + /> +
+
+ + +
+
+ + +
+ {addType === "grouped_counts" && ( +
+ + +
+ )} +
+ + + + +
+
+
+ ); +} diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 3424a73..175d4c6 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -16,7 +16,7 @@ import { XIcon, } from "lucide-react"; import { formatDistanceToNow } from "date-fns"; -import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers, getViews, createView, deleteView } from "@/lib/api"; +import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers, getViews, createView, deleteView, getDashboards } from "@/lib/api"; import type { CustomField, Lifecycle, Queue, QueueCustomField, SavedView, Ticket, User } from "@/lib/types"; import { Button } from "@/components/ui/button"; import { @@ -158,6 +158,7 @@ function TicketWorkbenchContent() { const [viewIdFromUrl, setViewIdFromUrl] = useState(null); const [saveViewOpen, setSaveViewOpen] = useState(false); const [saveViewName, setSaveViewName] = useState(""); + const [addFilterOpen, setAddFilterOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); const [newSubject, setNewSubject] = useState(""); @@ -253,6 +254,15 @@ function TicketWorkbenchContent() { void Promise.resolve().then(() => fetchData()); }, [fetchData]); + // Redirect to default dashboard if one exists and no params set + useEffect(() => { + if (searchParams.toString()) return; + getDashboards().then(({ data }) => { + const def = data?.find((d) => d.is_default); + if (def) router.replace(`/dashboards/${def.id}`); + }); + }, [searchParams]); + useEffect(() => { if (searchParams.get("new") === "true") { queueMicrotask(() => setDialogOpen(true)); @@ -315,6 +325,7 @@ function TicketWorkbenchContent() { getViews().then(({ data }) => { const view = data?.find((v) => v.id === paramViewId); if (view?.filters && Array.isArray(view.filters)) { + setSearchQuery(""); setFilters( (view.filters as { field: string; operator: string; value: string }[]) .filter((f) => f.field && f.value) @@ -329,6 +340,10 @@ function TicketWorkbenchContent() { if (view.sort_key) setSortKey(view.sort_key as SortKey); } }); + } else if (!paramViewId && viewIdFromUrl) { + // User navigated away from a view — clear filters + setFilters([]); + setSearchQuery(""); } }, [searchParams]); @@ -641,98 +656,112 @@ function TicketWorkbenchContent() {
- + {addFilterOpen && ( + <> +
setAddFilterOpen(false)} /> +
+
Queue
+ {queues.map((q) => ( + + ))} +
+
Owner
+ + {users.map((u) => ( + + ))} + {customFields.length > 0 && ( + <> +
+
Custom field
+ {customFields.map((cf) => ( + + ))} + + )} +
+ + )}
diff --git a/web/src/components/app-shell.tsx b/web/src/components/app-shell.tsx index d4ab20e..4499c13 100644 --- a/web/src/components/app-shell.tsx +++ b/web/src/components/app-shell.tsx @@ -13,8 +13,8 @@ import { PanelLeftIcon, CommandIcon, } from "lucide-react"; -import { getTickets, getQueues, getViews } from "@/lib/api"; -import type { Queue, SavedView } from "@/lib/types"; +import { getTickets, getQueues, getViews, getDashboards } from "@/lib/api"; +import type { Dashboard, Queue, SavedView } from "@/lib/types"; import { CommandPalette } from "@/components/command-palette"; import { ThemeToggle } from "@/components/theme-toggle"; import { cn } from "@/lib/utils"; @@ -87,6 +87,7 @@ function SidebarNav() { }); const [queues, setQueues] = useState<(Queue & { count: number })[]>([]); const [savedViews, setSavedViews] = useState([]); + const [dashboards, setDashboards] = useState([]); useEffect(() => { getTickets().then(({ data }) => { @@ -120,6 +121,10 @@ function SidebarNav() { getViews().then(({ data }) => { if (data) setSavedViews(data); }); + + getDashboards().then(({ data }) => { + if (data) setDashboards(data); + }); }, []); const collapsed = useSidebarCollapsed(); @@ -204,6 +209,29 @@ function SidebarNav() {
)} + {dashboards.length > 0 && ( +
+ {!collapsed && ( +
+ Dashboards +
+ )} + {dashboards.map((dash) => { + const active = + pathname.startsWith("/dashboards/") && pathname.endsWith(dash.id); + return ( + + ); + })} +
+ )} + {savedViews.length > 0 && (
{!collapsed && ( diff --git a/web/src/components/widgets/count-widget.tsx b/web/src/components/widgets/count-widget.tsx new file mode 100644 index 0000000..44e1ad4 --- /dev/null +++ b/web/src/components/widgets/count-widget.tsx @@ -0,0 +1,21 @@ +"use client"; + +import Link from "next/link"; +import type { WidgetData } from "@/lib/types"; + +export function CountWidget({ data }: { data: WidgetData }) { + const params = new URLSearchParams(); + if (data.view_id) params.set("view_id", data.view_id); + + return ( + + + {data.total} + + {data.title} + + ); +} diff --git a/web/src/components/widgets/grouped-counts-widget.tsx b/web/src/components/widgets/grouped-counts-widget.tsx new file mode 100644 index 0000000..2d5bc5c --- /dev/null +++ b/web/src/components/widgets/grouped-counts-widget.tsx @@ -0,0 +1,39 @@ +"use client"; + +import type { WidgetData } from "@/lib/types"; + +export function GroupedCountsWidget({ data }: { data: WidgetData }) { + const groups = data.groups ?? {}; + const entries = Object.entries(groups).sort(([, a], [, b]) => b - a); + const max = entries.length > 0 ? Math.max(...entries.map(([, c]) => c)) : 1; + + if (entries.length === 0) { + return ( +
+

No data

+
+ ); + } + + return ( +
+
+ {data.title} +
+
+ {entries.map(([label, count]) => ( +
+ {label} +
+
+
+ {count} +
+ ))} +
+
+ ); +} diff --git a/web/src/components/widgets/status-chart-widget.tsx b/web/src/components/widgets/status-chart-widget.tsx new file mode 100644 index 0000000..a63df3f --- /dev/null +++ b/web/src/components/widgets/status-chart-widget.tsx @@ -0,0 +1,76 @@ +"use client"; + +import type { WidgetData } from "@/lib/types"; + +const STATUS_COLORS: Record = { + new: "#64748b", + open: "#2563eb", + in_progress: "#d97706", + resolved: "#16a34a", + closed: "#71717a", +}; + +function statusLabel(status: string) { + return status.replaceAll("_", " "); +} + +export function StatusChartWidget({ data }: { data: WidgetData }) { + const counts = data.counts ?? {}; + const entries = Object.entries(counts).sort(([, a], [, b]) => b - a); + + if (entries.length === 0) { + return ( +
+

No data

+
+ ); + } + + return ( +
+
+ {data.title} +
+
+ {/* Donut */} + + {entries.map(([, count], index) => { + const total = entries.reduce((sum, [, c]) => sum + c, 0); + const offset = entries + .slice(0, index) + .reduce((sum, [, c]) => sum + (c / total) * 100, 0); + const pct = (count / total) * 100; + const circumference = 2 * Math.PI * 15; + const dash = (pct / 100) * circumference; + const color = STATUS_COLORS[entries[index][0]] ?? "#71717a"; + return ( + + ); + })} + + {/* Legend */} +
+ {entries.map(([status, count]) => ( +
+ + {statusLabel(status)} + {count} +
+ ))} +
+
+
+ ); +} diff --git a/web/src/components/widgets/ticket-list-widget.tsx b/web/src/components/widgets/ticket-list-widget.tsx new file mode 100644 index 0000000..e3a1d7d --- /dev/null +++ b/web/src/components/widgets/ticket-list-widget.tsx @@ -0,0 +1,59 @@ +"use client"; + +import Link from "next/link"; +import { formatDistanceToNow } from "date-fns"; +import { CircleIcon } from "lucide-react"; +import type { WidgetData } from "@/lib/types"; +import { cn, formatTicketId } from "@/lib/utils"; + +const STATUS_COLORS: Record = { + new: "#64748b", + open: "#2563eb", + in_progress: "#d97706", + resolved: "#16a34a", + closed: "#71717a", +}; + +function statusLabel(status: string) { + return status.replaceAll("_", " "); +} + +export function TicketListWidget({ data }: { data: WidgetData }) { + const tickets = data.tickets ?? []; + + return ( +
+
+ {data.title} + {data.total} +
+
+ {tickets.length === 0 ? ( +

No tickets

+ ) : ( + tickets.map((ticket) => ( + + + + {ticket.subject} + + + {ticket.owner_name ?? "unassigned"} + + + {formatDistanceToNow(new Date(ticket.updated_at), { addSuffix: true })} + + + )) + )} +
+
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c2d20ad..70b7e2c 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,6 +1,9 @@ import type { Ticket, Queue, + Dashboard, + DashboardWidget, + WidgetData, User, Transaction, SavedView, @@ -259,3 +262,54 @@ export async function updateView(id: string, data: { export async function deleteView(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> { return request<{ ok: boolean }>(`/views/${id}`, { method: "DELETE" }); } + +export async function getDashboards(): Promise<{ data: Dashboard[] | null; error: string | null }> { + return request("/dashboards"); +} + +export async function getDashboard(id: string): Promise<{ data: Dashboard | null; error: string | null }> { + return request(`/dashboards/${id}`); +} + +export async function createDashboard(data: { + name: string; + description?: string; + is_default?: boolean; +}): Promise<{ data: Dashboard | null; error: string | null }> { + return request("/dashboards", { method: "POST", body: JSON.stringify(data) }); +} + +export async function updateDashboard(id: string, data: { + name?: string; + description?: string | null; + is_default?: boolean; + layout?: unknown[]; +}): Promise<{ data: Dashboard | null; error: string | null }> { + return request(`/dashboards/${id}`, { method: "PATCH", body: JSON.stringify(data) }); +} + +export async function deleteDashboard(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> { + return request<{ ok: boolean }>(`/dashboards/${id}`, { method: "DELETE" }); +} + +export async function getDashboardWidgets(dashboardId: string): Promise<{ data: DashboardWidget[] | null; error: string | null }> { + return request(`/dashboards/${dashboardId}/widgets`); +} + +export async function createWidget(dashboardId: string, data: { + view_id: string; + title: string; + widget_type: string; + position?: { x: number; y: number; w: number; h: number }; + config?: Record; +}): Promise<{ data: DashboardWidget | null; error: string | null }> { + return request(`/dashboards/${dashboardId}/widgets`, { method: "POST", body: JSON.stringify(data) }); +} + +export async function deleteWidget(dashboardId: string, widgetId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> { + return request<{ ok: boolean }>(`/dashboards/${dashboardId}/widgets/${widgetId}`, { method: "DELETE" }); +} + +export async function getWidgetData(dashboardId: string, widgetId: string): Promise<{ data: WidgetData | null; error: string | null }> { + return request(`/dashboards/${dashboardId}/widgets/${widgetId}/data`); +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 521b094..3ff069d 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -145,3 +145,45 @@ export interface SavedView { creator_id: string | null; created_at: string; } + +export interface Dashboard { + id: string; + name: string; + description: string | null; + layout: unknown[]; + is_default: boolean; + created_at: string; + widgets?: DashboardWidget[]; +} + +export interface DashboardWidget { + id: string; + dashboard_id: string; + view_id: string; + title: string; + widget_type: string; + position: { x: number; y: number; w: number; h: number }; + config: Record; + created_at: string; +} + +export interface WidgetTicket { + id: number; + subject: string; + status: string; + owner_id: string | null; + owner_name: string | null; + queue_name: string; + updated_at: string; +} + +export interface WidgetData { + type: string; + title: string; + total: number; + view_id: string; + tickets?: WidgetTicket[]; + counts?: Record; + groups?: Record; + group_by?: string; +}