From aa90b889915fa49bfe0a364dcff5ab419a8e3c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gjermund=20H=C3=B8s=C3=B8ien=20Wiggen?= Date: Tue, 9 Jun 2026 11:10:25 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20add=20saved=20views=20=E2=80=94=20datab?= =?UTF-8?q?ase=20table,=20CRUD=20API,=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New views table (id, name, filters jsonb, sort_key, is_public, creator_id) - GET/POST/PATCH/DELETE /views endpoints - Register views router in server Co-Authored-By: Claude Opus 4.8 --- drizzle/migrations/0003_dry_caretaker.sql | 12 + drizzle/migrations/meta/0003_snapshot.json | 1011 ++++++++++++++++++++ drizzle/migrations/meta/_journal.json | 9 +- src/db/schema.ts | 11 + src/index.ts | 2 + src/routes/views.ts | 84 ++ web/src/app/page.tsx | 562 ++++++++--- web/src/components/app-shell.tsx | 32 +- web/src/lib/api.ts | 29 + web/src/lib/types.ts | 17 + 10 files changed, 1615 insertions(+), 154 deletions(-) create mode 100644 drizzle/migrations/0003_dry_caretaker.sql create mode 100644 drizzle/migrations/meta/0003_snapshot.json create mode 100644 src/routes/views.ts diff --git a/drizzle/migrations/0003_dry_caretaker.sql b/drizzle/migrations/0003_dry_caretaker.sql new file mode 100644 index 0000000..5430148 --- /dev/null +++ b/drizzle/migrations/0003_dry_caretaker.sql @@ -0,0 +1,12 @@ +CREATE TABLE "views" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "filters" jsonb DEFAULT '[]' NOT NULL, + "sort_key" text DEFAULT 'updated', + "columns" jsonb DEFAULT '[]', + "is_public" boolean DEFAULT false, + "creator_id" uuid, + "created_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "views" ADD CONSTRAINT "views_creator_id_users_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/migrations/meta/0003_snapshot.json b/drizzle/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..82f160a --- /dev/null +++ b/drizzle/migrations/meta/0003_snapshot.json @@ -0,0 +1,1011 @@ +{ + "id": "6c5e0b0c-45b7-4ff0-9979-9fc22f187b0f", + "prevId": "042752b4-e1ad-4b6d-96ed-81f836028826", + "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.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 5a065b8..7f2ca27 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1780904200000, "tag": "0002_short_custom_field_keys", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1780995910694, + "tag": "0003_dry_caretaker", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 1ccede2..ad6b6d1 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -112,3 +112,14 @@ export const customFieldValues = pgTable('custom_field_values', { ticketIdIdx: index('custom_field_values_ticket_id_idx').on(table.ticket_id), cfIdIdx: index('custom_field_values_custom_field_id_idx').on(table.custom_field_id), })); + +export const views = pgTable('views', { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + filters: jsonb('filters').notNull().default('[]'), + sort_key: text('sort_key').default('updated'), + columns: jsonb('columns').default('[]'), + is_public: boolean('is_public').default(false), + creator_id: uuid('creator_id').references(() => users.id), + created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), +}); diff --git a/src/index.ts b/src/index.ts index 44e2c71..3489a94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { createCustomFieldsRouter } from './routes/custom-fields.ts'; import { createLifecyclesRouter } from './routes/lifecycles.ts'; import { createUsersRouter } from './routes/users.ts'; import { createTemplatesRouter } from './routes/templates.ts'; +import { createViewsRouter } from './routes/views.ts'; let db: Db | null = null; @@ -35,6 +36,7 @@ app.route('/custom-fields', createCustomFieldsRouter(getDb())); app.route('/lifecycles', createLifecyclesRouter(getDb())); app.route('/users', createUsersRouter(getDb())); app.route('/templates', createTemplatesRouter(getDb())); +app.route('/views', createViewsRouter(getDb())); export default app; export { app }; diff --git a/src/routes/views.ts b/src/routes/views.ts new file mode 100644 index 0000000..dfe867d --- /dev/null +++ b/src/routes/views.ts @@ -0,0 +1,84 @@ +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { asc, eq } from 'drizzle-orm'; +import type { Db } from '../db/index.ts'; +import { views } from '../db/schema.ts'; + +export function createViewsRouter(db: Db): Hono { + const router = new Hono(); + + router.get('/', async (c) => { + const result = await db.query.views.findMany({ + orderBy: asc(views.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 [view] = await db.insert(views).values({ + name, + filters: body.filters ?? [], + sort_key: body.sort_key ?? 'updated', + columns: body.columns ?? [], + is_public: body.is_public ?? false, + creator_id: body.creator_id || null, + }).returning(); + + if (!view) { + throw new HTTPException(500, { message: 'Failed to create view' }); + } + + return c.json(view, 201); + }); + + router.patch('/:id', async (c) => { + const id = c.req.param('id'); + const body = await c.req.json(); + + const existing = await db.query.views.findFirst({ + where: eq(views.id, id), + }); + + if (!existing) { + throw new HTTPException(404, { message: 'View not found' }); + } + + const updateData: Partial = {}; + if (body.name !== undefined) updateData.name = String(body.name).trim(); + if (body.filters !== undefined) updateData.filters = body.filters; + if (body.sort_key !== undefined) updateData.sort_key = body.sort_key; + if (body.columns !== undefined) updateData.columns = body.columns; + if (body.is_public !== undefined) updateData.is_public = body.is_public; + + const [updated] = await db.update(views) + .set(updateData) + .where(eq(views.id, id)) + .returning(); + + return c.json(updated); + }); + + router.delete('/:id', async (c) => { + const id = c.req.param('id'); + + const existing = await db.query.views.findFirst({ + where: eq(views.id, id), + }); + + if (!existing) { + throw new HTTPException(404, { message: 'View not found' }); + } + + await db.delete(views).where(eq(views.id, id)); + return c.json({ ok: true }); + }); + + return router; +} diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index a255963..3424a73 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -10,12 +10,14 @@ import { LayoutListIcon, PlusIcon, RefreshCwIcon, + SaveIcon, SearchIcon, SlidersHorizontalIcon, + XIcon, } from "lucide-react"; import { formatDistanceToNow } from "date-fns"; -import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers } from "@/lib/api"; -import type { CustomField, Lifecycle, Queue, QueueCustomField, Ticket, User } from "@/lib/types"; +import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers, getViews, createView, deleteView } from "@/lib/api"; +import type { CustomField, Lifecycle, Queue, QueueCustomField, SavedView, Ticket, User } from "@/lib/types"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -44,6 +46,20 @@ const VIEW_LABELS: Record = { type Density = "comfortable" | "compact"; type SortKey = "updated" | "created" | "id"; +interface Filter { + id: string; + field: string; // "status" | "queue" | "owner" | custom field key ("cf.") + operator: string; // "is" | "is_not" + value: string; + label: string; // human-readable label for the chip +} + +function buildFilterLabel(field: string, operator: string, valueLabel: string): string { + const fieldLabel = field.startsWith("cf.") ? field.slice(3) : field; + const op = operator === "is_not" ? "is not" : "is"; + return `${fieldLabel} ${op} ${valueLabel}`; +} + function statusLabel(status: string) { return STATUS_META[status]?.label ?? status.replaceAll("_", " "); } @@ -133,14 +149,16 @@ function TicketWorkbenchContent() { const [selectedId, setSelectedId] = useState(null); const [searchQuery, setSearchQuery] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - const [queueFilter, setQueueFilter] = useState("all"); - const [ownerFilter, setOwnerFilter] = useState("all"); - const [customFieldFilter, setCustomFieldFilter] = useState("none"); - const [customFieldValue, setCustomFieldValue] = useState(""); + const [filters, setFilters] = useState([]); const [density, setDensity] = useState("comfortable"); const [sortKey, setSortKey] = useState("updated"); + // Saved views + const [savedViewsList, setSavedViewsList] = useState([]); + const [viewIdFromUrl, setViewIdFromUrl] = useState(null); + const [saveViewOpen, setSaveViewOpen] = useState(false); + const [saveViewName, setSaveViewName] = useState(""); + const [dialogOpen, setDialogOpen] = useState(false); const [newSubject, setNewSubject] = useState(""); const [newQueueId, setNewQueueId] = useState(""); @@ -164,19 +182,24 @@ function TicketWorkbenchContent() { setError(null); const fetchedAt = Date.now(); - const activeQueue = routeQueue || queueFilter; - const customFieldFilters = - customFieldFilter !== "none" && customFieldValue.trim() - ? { [customFieldFilter]: customFieldValue.trim() } - : undefined; + const activeQueue = routeQueue; + const apiStatus = filters.find((f) => f.field === "status")?.value; + const apiOwner = filters.find((f) => f.field === "owner")?.value; + const apiQueue = filters.find((f) => f.field === "queue")?.value; + const customFieldFilters: Record = {}; + for (const f of filters) { + if (f.field.startsWith("cf.")) { + customFieldFilters[f.field.slice(3)] = f.value; + } + } const [ticketsRes, queuesRes, usersRes, fieldsRes, lifecycleRes] = await Promise.all([ getTickets({ q: searchQuery.trim() || undefined, - status: statusFilter !== "all" ? statusFilter : undefined, - queue_id: activeQueue && activeQueue !== "all" ? activeQueue : undefined, - owner_id: ownerFilter !== "all" ? ownerFilter : undefined, - custom_fields: customFieldFilters, + status: apiStatus || undefined, + queue_id: activeQueue || apiQueue || undefined, + owner_id: apiOwner || undefined, + custom_fields: Object.keys(customFieldFilters).length > 0 ? customFieldFilters : undefined, }), getQueues(), getUsers(), @@ -223,7 +246,7 @@ function TicketWorkbenchContent() { setRefreshing(false); setClock(fetchedAt); }, - [customFieldFilter, customFieldValue, newQueueId, ownerFilter, queueFilter, routeQueue, searchQuery, statusFilter] + [filters, newQueueId, routeQueue, searchQuery] ); useEffect(() => { @@ -277,8 +300,41 @@ function TicketWorkbenchContent() { }; }, [newQueueId]); + // Load saved views list + useEffect(() => { + getViews().then(({ data }) => { + if (data) setSavedViewsList(data); + }); + }, [clock]); + + // Load view from URL param + useEffect(() => { + const paramViewId = searchParams.get("view_id"); + setViewIdFromUrl(paramViewId); + if (paramViewId) { + getViews().then(({ data }) => { + const view = data?.find((v) => v.id === paramViewId); + if (view?.filters && Array.isArray(view.filters)) { + setFilters( + (view.filters as { field: string; operator: string; value: string }[]) + .filter((f) => f.field && f.value) + .map((f) => ({ + id: crypto.randomUUID(), + field: f.field, + operator: f.operator || "is", + value: f.value, + label: buildFilterLabel(f.field, f.operator || "is", f.value), + })) + ); + if (view.sort_key) setSortKey(view.sort_key as SortKey); + } + }); + } + }, [searchParams]); + const statusOptions = useMemo(() => { - const selectedFilterQueueId = routeQueue || (queueFilter !== "all" ? queueFilter : ""); + const queueFilterValue = filters.find((f) => f.field === "queue")?.value; + const selectedFilterQueueId = routeQueue || queueFilterValue || ""; const selectedFilterQueue = selectedFilterQueueId ? queues.find((queue) => queue.id === selectedFilterQueueId) : null; @@ -303,7 +359,7 @@ function TicketWorkbenchContent() { .filter(Boolean) .map((status) => ({ key: status, label: statusLabel(status) })), ]; - }, [lifecycles, queueFilter, queues, routeQueue, tickets]); + }, [filters, lifecycles, queues, routeQueue, tickets]); const inactiveStatuses = useMemo( () => new Set( @@ -329,7 +385,9 @@ function TicketWorkbenchContent() { const filteredTickets = useMemo(() => { const query = searchQuery.trim().toLowerCase(); const now = clock || 0; - const queue = routeQueue || queueFilter; + const queue = routeQueue; + const statusFilterValue = filters.find((f) => f.field === "status")?.value; + const queueFilterValue = filters.find((f) => f.field === "queue")?.value; return tickets .filter((ticket) => { @@ -339,8 +397,9 @@ function TicketWorkbenchContent() { const week = 7 * 24 * 60 * 60 * 1000; if (!now || now - new Date(ticket.updated_at).getTime() > week) return false; } - if (statusFilter !== "all" && ticket.status !== statusFilter) return false; - if (queue && queue !== "all" && ticket.queue_id !== queue) return false; + if (statusFilterValue && ticket.status !== statusFilterValue) return false; + if (queueFilterValue && ticket.queue_id !== queueFilterValue) return false; + if (queue && ticket.queue_id !== queue) return false; if (!query) return true; return ( ticket.subject.toLowerCase().includes(query) || @@ -355,17 +414,13 @@ function TicketWorkbenchContent() { const bDate = sortKey === "created" ? b.created_at : b.updated_at; return new Date(bDate).getTime() - new Date(aDate).getTime(); }); - }, [clock, queueFilter, queues, routeQueue, searchQuery, sortKey, statusFilter, tickets, view]); + }, [clock, filters, queues, routeQueue, searchQuery, sortKey, tickets, view]); const selectedTicket = filteredTickets.find((ticket) => ticket.id === selectedId) ?? filteredTickets[0] ?? null; const visibleTitle = routeQueue ? queueName(queues, routeQueue) : VIEW_LABELS[view] ?? "All tickets"; - const selectedCustomField = customFields.find((field) => field.key === customFieldFilter); - const selectedCustomFieldOptions = Array.isArray(selectedCustomField?.values) - ? selectedCustomField.values.map((value) => String(value)) - : []; const newTicketFields = newQueueFields .map((assignment) => assignment.custom_field) .filter((field): field is CustomField => Boolean(field)); @@ -374,20 +429,11 @@ function TicketWorkbenchContent() { ? lifecycles.find((lifecycle) => lifecycle.id === selectedNewQueue.lifecycle_id) : null; const newTicketInitialStatus = selectedNewLifecycle?.definition.statuses.initial[0] ?? "new"; - const hasQueryFilters = - searchQuery.trim() || - statusFilter !== "all" || - queueFilter !== "all" || - ownerFilter !== "all" || - (customFieldFilter !== "none" && customFieldValue.trim()); + const hasQueryFilters = searchQuery.trim() || filters.length > 0; const clearQueryFilters = () => { setSearchQuery(""); - setStatusFilter("all"); - setQueueFilter("all"); - setOwnerFilter("all"); - setCustomFieldFilter("none"); - setCustomFieldValue(""); + setFilters([]); if (routeQueue) router.push("/"); }; @@ -458,6 +504,20 @@ function TicketWorkbenchContent() { Refresh + {filters.length > 0 && ( + + )} - ))} - - - - -
+ Clear + + )}
- -
- + {/* Row 2: status quick-filter pills */} +
+ {statusOptions.map((opt) => { + const isActive = opt.key === "all" + ? !filters.some((f) => f.field === "status") + : filters.some((f) => f.field === "status" && f.value === opt.key); + return ( + + ); + })} +
- - - {selectedCustomFieldOptions.length > 0 ? ( - - ) : ( - setCustomFieldValue(event.target.value)} - disabled={customFieldFilter === "none"} - placeholder={customFieldFilter === "none" ? "Select a field first" : "Field value"} - className="h-9 rounded-md border border-input bg-card/90 px-3 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring disabled:opacity-55" - /> - )} +
+ + +
+
+ + {/* Inline value inputs for custom field filters */} + {filters + .filter((f) => f.field.startsWith("cf.") && f.value === "") + .map((f) => { + const cf = customFields.find((x) => x.key === f.field.slice(3)); + const options = Array.isArray(cf?.values) + ? cf.values.map((v) => String(v)) + : []; + return ( +
+ {cf?.name ?? f.field.slice(3)}: + {options.length > 0 ? ( + + ) : ( +
+ { + if (event.key === "Enter") { + const val = event.currentTarget.value.trim(); + if (val) { + setFilters((prev) => + prev.map((x) => + x.id === f.id + ? { ...x, value: val, label: buildFilterLabel(x.field, "is", val) } + : x + ) + ); + } + } + }} + /> + +
+ )} +
+ ); + })} @@ -892,6 +1082,76 @@ function TicketWorkbenchContent() { + + + + + Save view + + Save the current filters as a named view. It will appear in your sidebar. + + + setSaveViewName(event.target.value)} + placeholder="View name" + className="h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring" + autoFocus + onKeyDown={async (event) => { + if (event.key === "Enter" && saveViewName.trim()) { + const storedFilters = filters.map((f) => ({ + field: f.field, + operator: f.operator, + value: f.value, + })); + const { data, error } = await createView({ + name: saveViewName.trim(), + filters: storedFilters, + sort_key: sortKey, + }); + if (!error && data) { + setSavedViewsList((prev) => [...prev, data]); + setSaveViewOpen(false); + setSaveViewName(""); + } + } + }} + /> + + + + + + ); } diff --git a/web/src/components/app-shell.tsx b/web/src/components/app-shell.tsx index 0229f48..d4ab20e 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 } from "@/lib/api"; -import type { Queue } from "@/lib/types"; +import { getTickets, getQueues, getViews } from "@/lib/api"; +import type { Queue, SavedView } from "@/lib/types"; import { CommandPalette } from "@/components/command-palette"; import { ThemeToggle } from "@/components/theme-toggle"; import { cn } from "@/lib/utils"; @@ -86,6 +86,7 @@ function SidebarNav() { recent: 0, }); const [queues, setQueues] = useState<(Queue & { count: number })[]>([]); + const [savedViews, setSavedViews] = useState([]); useEffect(() => { getTickets().then(({ data }) => { @@ -115,6 +116,10 @@ function SidebarNav() { ).then(setQueues); } }); + + getViews().then(({ data }) => { + if (data) setSavedViews(data); + }); }, []); const collapsed = useSidebarCollapsed(); @@ -198,6 +203,29 @@ function SidebarNav() { })} )} + + {savedViews.length > 0 && ( +
+ {!collapsed && ( +
+ Saved views +
+ )} + {savedViews.map((view) => { + const active = + pathname === "/" && searchParams.get("view_id") === view.id; + return ( + + ); + })} +
+ )} ); } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 74f0b32..c2d20ad 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -3,6 +3,7 @@ import type { Queue, User, Transaction, + SavedView, Scrip, Template, TemplatePreview, @@ -230,3 +231,31 @@ export async function updateCustomField(id: string, data: { }): Promise<{ data: CustomField | null; error: string | null }> { return request(`/custom-fields/${id}`, { method: "PATCH", body: JSON.stringify(data) }); } + +export async function getViews(): Promise<{ data: SavedView[] | null; error: string | null }> { + return request("/views"); +} + +export async function createView(data: { + name: string; + filters: { field: string; operator: string; value: string }[]; + sort_key?: string; + columns?: unknown[]; + is_public?: boolean; +}): Promise<{ data: SavedView | null; error: string | null }> { + return request("/views", { method: "POST", body: JSON.stringify(data) }); +} + +export async function updateView(id: string, data: { + name?: string; + filters?: { field: string; operator: string; value: string }[]; + sort_key?: string; + columns?: unknown[]; + is_public?: boolean; +}): Promise<{ data: SavedView | null; error: string | null }> { + return request(`/views/${id}`, { method: "PATCH", body: JSON.stringify(data) }); +} + +export async function deleteView(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> { + return request<{ ok: boolean }>(`/views/${id}`, { method: "DELETE" }); +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 188b8d8..521b094 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -128,3 +128,20 @@ export interface ScripResult { success: boolean; message: string; } + +export interface SavedFilter { + field: string; + operator: string; + value: string; +} + +export interface SavedView { + id: string; + name: string; + filters: SavedFilter[]; + sort_key: string; + columns: unknown[]; + is_public: boolean; + creator_id: string | null; + created_at: string; +}