From 08cc83e2b29e44abe7ad8b430bf17399e6316bb9 Mon Sep 17 00:00:00 2001 From: Kevin Guo Date: Sat, 18 Apr 2026 12:24:49 -0400 Subject: [PATCH] feat: add event categories with admin settings and volunteer visibility --- drizzle/0004_tiny_mattie_franklin.sql | 11 + drizzle/meta/0004_snapshot.json | 1708 +++++++++++++++++ drizzle/meta/_journal.json | 7 + .../api/staff/calendar/events/[id]/route.ts | 2 + src/app/api/staff/calendar/events/route.ts | 2 + src/app/api/staff/event-categories/route.ts | 21 + .../settings/event-categories/[id]/route.ts | 90 + .../staff/settings/event-categories/route.ts | 56 + .../event-categories-settings-client.tsx | 340 ++++ .../staff/settings/event-categories/page.tsx | 8 + src/app/volunteer/opportunities/page.tsx | 8 + src/components/layout/settings-nav.tsx | 4 + .../staff/calendar/event-detail-modal.tsx | 58 + .../staff/calendar/event-form-modal.tsx | 30 + .../volunteer/opportunity-detail-modal.tsx | 17 +- src/components/volunteer/types.ts | 2 + src/db/schema/opportunities.ts | 22 + src/hooks/use-calendar-events.ts | 15 +- src/hooks/use-event-categories.ts | 141 ++ src/services/calendar-events.service.ts | 63 +- src/services/event-categories.service.ts | 102 + src/services/opportunities.service.ts | 14 +- src/services/recommendation.service.ts | 2 + 23 files changed, 2713 insertions(+), 10 deletions(-) create mode 100644 drizzle/0004_tiny_mattie_franklin.sql create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 src/app/api/staff/event-categories/route.ts create mode 100644 src/app/api/staff/settings/event-categories/[id]/route.ts create mode 100644 src/app/api/staff/settings/event-categories/route.ts create mode 100644 src/app/staff/settings/event-categories/event-categories-settings-client.tsx create mode 100644 src/app/staff/settings/event-categories/page.tsx create mode 100644 src/hooks/use-event-categories.ts create mode 100644 src/services/event-categories.service.ts diff --git a/drizzle/0004_tiny_mattie_franklin.sql b/drizzle/0004_tiny_mattie_franklin.sql new file mode 100644 index 0000000..4f0c902 --- /dev/null +++ b/drizzle/0004_tiny_mattie_franklin.sql @@ -0,0 +1,11 @@ +CREATE TABLE "event_categories" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "event_categories_name_unique" UNIQUE("name") +); +--> statement-breakpoint +ALTER TABLE "opportunities" ADD COLUMN "category_id" integer;--> statement-breakpoint +ALTER TABLE "opportunities" ADD CONSTRAINT "opportunities_category_id_event_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."event_categories"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..baf6f9b --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1708 @@ +{ + "id": "b0f511fa-1d57-4ca0-8b51-8c78d3c0217f", + "prevId": "586f6b29-8bbd-4c4e-890d-5ed7d776d2ee", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_dashboard_actions": { + "name": "admin_dashboard_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "admin_id": { + "name": "admin_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_dashboard_actions_admin_id_users_id_fk": { + "name": "admin_dashboard_actions_admin_id_users_id_fk", + "tableFrom": "admin_dashboard_actions", + "tableTo": "users", + "columnsFrom": [ + "admin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_id": { + "name": "updated_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "system_settings_updated_by_id_users_id_fk": { + "name": "system_settings_updated_by_id_users_id_fk", + "tableFrom": "system_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "system_settings_key_unique": { + "name": "system_settings_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bulk_communication_logs": { + "name": "bulk_communication_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_type": { + "name": "recipient_type", + "type": "recipient_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sent'" + } + }, + "indexes": {}, + "foreignKeys": { + "bulk_communication_logs_sender_id_users_id_fk": { + "name": "bulk_communication_logs_sender_id_users_id_fk", + "tableFrom": "bulk_communication_logs", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.communication_templates": { + "name": "communication_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding_documents": { + "name": "onboarding_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sign'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "required": { + "name": "required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_document_signatures": { + "name": "volunteer_document_signatures", + "schema": "", + "columns": { + "volunteer_id": { + "name": "volunteer_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "signed_at": { + "name": "signed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consent_given": { + "name": "consent_given", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "vds_document_id_idx": { + "name": "vds_document_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "volunteer_document_signatures_volunteer_id_volunteers_id_fk": { + "name": "volunteer_document_signatures_volunteer_id_volunteers_id_fk", + "tableFrom": "volunteer_document_signatures", + "tableTo": "volunteers", + "columnsFrom": [ + "volunteer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_document_signatures_document_id_onboarding_documents_id_fk": { + "name": "volunteer_document_signatures_document_id_onboarding_documents_id_fk", + "tableFrom": "volunteer_document_signatures", + "tableTo": "onboarding_documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "volunteer_document_signatures_volunteer_id_document_id_pk": { + "name": "volunteer_document_signatures_volunteer_id_document_id_pk", + "columns": [ + "volunteer_id", + "document_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_categories": { + "name": "event_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "event_categories_name_unique": { + "name": "event_categories_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.opportunities": { + "name": "opportunities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "opportunity_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "max_volunteers": { + "name": "max_volunteers", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "recurrence_pattern": { + "name": "recurrence_pattern", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_recurring": { + "name": "is_recurring", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "opportunities_created_by_id_users_id_fk": { + "name": "opportunities_created_by_id_users_id_fk", + "tableFrom": "opportunities", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "opportunities_category_id_event_categories_id_fk": { + "name": "opportunities_category_id_event_categories_id_fk", + "tableFrom": "opportunities", + "tableTo": "event_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.opportunity_interests": { + "name": "opportunity_interests", + "schema": "", + "columns": { + "opportunity_id": { + "name": "opportunity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "interest_id": { + "name": "interest_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "opportunity_interests_opportunity_id_opportunities_id_fk": { + "name": "opportunity_interests_opportunity_id_opportunities_id_fk", + "tableFrom": "opportunity_interests", + "tableTo": "opportunities", + "columnsFrom": [ + "opportunity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opportunity_interests_interest_id_interests_id_fk": { + "name": "opportunity_interests_interest_id_interests_id_fk", + "tableFrom": "opportunity_interests", + "tableTo": "interests", + "columnsFrom": [ + "interest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "opportunity_interests_opportunity_id_interest_id_pk": { + "name": "opportunity_interests_opportunity_id_interest_id_pk", + "columns": [ + "opportunity_id", + "interest_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.opportunity_required_skills": { + "name": "opportunity_required_skills", + "schema": "", + "columns": { + "opportunity_id": { + "name": "opportunity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "opportunity_required_skills_opportunity_id_opportunities_id_fk": { + "name": "opportunity_required_skills_opportunity_id_opportunities_id_fk", + "tableFrom": "opportunity_required_skills", + "tableTo": "opportunities", + "columnsFrom": [ + "opportunity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opportunity_required_skills_skill_id_skills_id_fk": { + "name": "opportunity_required_skills_skill_id_skills_id_fk", + "tableFrom": "opportunity_required_skills", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "opportunity_required_skills_opportunity_id_skill_id_pk": { + "name": "opportunity_required_skills_opportunity_id_skill_id_pk", + "columns": [ + "opportunity_id", + "skill_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_hours": { + "name": "volunteer_hours", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "volunteer_id": { + "name": "volunteer_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "hours": { + "name": "hours", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "hours_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "volunteer_hours_volunteer_id_volunteers_id_fk": { + "name": "volunteer_hours_volunteer_id_volunteers_id_fk", + "tableFrom": "volunteer_hours", + "tableTo": "volunteers", + "columnsFrom": [ + "volunteer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_hours_opportunity_id_opportunities_id_fk": { + "name": "volunteer_hours_opportunity_id_opportunities_id_fk", + "tableFrom": "volunteer_hours", + "tableTo": "opportunities", + "columnsFrom": [ + "opportunity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_hours_verified_by_users_id_fk": { + "name": "volunteer_hours_verified_by_users_id_fk", + "tableFrom": "volunteer_hours", + "tableTo": "users", + "columnsFrom": [ + "verified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_rsvps": { + "name": "volunteer_rsvps", + "schema": "", + "columns": { + "volunteer_id": { + "name": "volunteer_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "rsvp_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "rsvp_at": { + "name": "rsvp_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "volunteer_rsvps_volunteer_id_volunteers_id_fk": { + "name": "volunteer_rsvps_volunteer_id_volunteers_id_fk", + "tableFrom": "volunteer_rsvps", + "tableTo": "volunteers", + "columnsFrom": [ + "volunteer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_rsvps_opportunity_id_opportunities_id_fk": { + "name": "volunteer_rsvps_opportunity_id_opportunities_id_fk", + "tableFrom": "volunteer_rsvps", + "tableTo": "opportunities", + "columnsFrom": [ + "opportunity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "volunteer_rsvps_volunteer_id_opportunity_id_pk": { + "name": "volunteer_rsvps_volunteer_id_opportunity_id_pk", + "columns": [ + "volunteer_id", + "opportunity_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin": { + "name": "admin", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_staff_id_staff_id_fk": { + "name": "admin_staff_id_staff_id_fk", + "tableFrom": "admin", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interests": { + "name": "interests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "interests_name_unique": { + "name": "interests_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_name_unique": { + "name": "skills_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff": { + "name": "staff", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "notification_preference": { + "name": "notification_preference", + "type": "notification_preference", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'email'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "staff_user_id_users_id_fk": { + "name": "staff_user_id_users_id_fk", + "tableFrom": "staff", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_user_id_unique": { + "name": "staff_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "profile_picture": { + "name": "profile_picture", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_email_verified": { + "name": "is_email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_interests": { + "name": "volunteer_interests", + "schema": "", + "columns": { + "volunteer_id": { + "name": "volunteer_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "interest_id": { + "name": "interest_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "volunteer_interests_volunteer_id_volunteers_id_fk": { + "name": "volunteer_interests_volunteer_id_volunteers_id_fk", + "tableFrom": "volunteer_interests", + "tableTo": "volunteers", + "columnsFrom": [ + "volunteer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_interests_interest_id_interests_id_fk": { + "name": "volunteer_interests_interest_id_interests_id_fk", + "tableFrom": "volunteer_interests", + "tableTo": "interests", + "columnsFrom": [ + "interest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "volunteer_interests_volunteer_id_interest_id_pk": { + "name": "volunteer_interests_volunteer_id_interest_id_pk", + "columns": [ + "volunteer_id", + "interest_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_skills": { + "name": "volunteer_skills", + "schema": "", + "columns": { + "volunteer_id": { + "name": "volunteer_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "proficiency_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "volunteer_skills_volunteer_id_volunteers_id_fk": { + "name": "volunteer_skills_volunteer_id_volunteers_id_fk", + "tableFrom": "volunteer_skills", + "tableTo": "volunteers", + "columnsFrom": [ + "volunteer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_skills_skill_id_skills_id_fk": { + "name": "volunteer_skills_skill_id_skills_id_fk", + "tableFrom": "volunteer_skills", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "volunteer_skills_volunteer_id_skill_id_pk": { + "name": "volunteer_skills_volunteer_id_skill_id_pk", + "columns": [ + "volunteer_id", + "skill_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_types": { + "name": "volunteer_types", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "volunteer_types_name_unique": { + "name": "volunteer_types_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteers": { + "name": "volunteers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "volunteer_type": { + "name": "volunteer_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_alumni": { + "name": "is_alumni", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "background_check_status": { + "name": "background_check_status", + "type": "background_check_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'not_required'" + }, + "availability": { + "name": "availability", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notification_preference": { + "name": "notification_preference", + "type": "notification_preference", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'email'" + }, + "employer": { + "name": "employer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referral_source": { + "name": "referral_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "volunteers_user_id_users_id_fk": { + "name": "volunteers_user_id_users_id_fk", + "tableFrom": "volunteers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "volunteers_user_id_unique": { + "name": "volunteers_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.background_check_status": { + "name": "background_check_status", + "schema": "public", + "values": [ + "not_required", + "pending", + "approved", + "rejected" + ] + }, + "public.hours_status": { + "name": "hours_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected" + ] + }, + "public.notification_preference": { + "name": "notification_preference", + "schema": "public", + "values": [ + "email", + "sms", + "both", + "none" + ] + }, + "public.opportunity_status": { + "name": "opportunity_status", + "schema": "public", + "values": [ + "open", + "full", + "completed", + "canceled" + ] + }, + "public.proficiency_level": { + "name": "proficiency_level", + "schema": "public", + "values": [ + "no_selection", + "beginner", + "intermediate", + "advanced" + ] + }, + "public.recipient_type": { + "name": "recipient_type", + "schema": "public", + "values": [ + "volunteers", + "staff", + "both" + ] + }, + "public.rsvp_status": { + "name": "rsvp_status", + "schema": "public", + "values": [ + "pending", + "confirmed", + "declined", + "attended", + "no_show", + "cancelled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 48cfeef..1c87008 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1776511347486, "tag": "0003_normal_blindfold", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1776528627526, + "tag": "0004_tiny_mattie_franklin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/staff/calendar/events/[id]/route.ts b/src/app/api/staff/calendar/events/[id]/route.ts index 0cba431..22853bf 100644 --- a/src/app/api/staff/calendar/events/[id]/route.ts +++ b/src/app/api/staff/calendar/events/[id]/route.ts @@ -23,6 +23,7 @@ const eventUpdateSchema = z.object({ endDate: z.string().datetime("Invalid end date").optional(), maxVolunteers: z.number().int().positive().optional(), status: z.enum(["open", "full", "completed", "canceled"]).optional(), + categoryId: z.number().int().positive().nullable().optional(), }); /** @@ -54,6 +55,7 @@ export async function PUT( endDate: data.endDate ? new Date(data.endDate) : undefined, maxVolunteers: data.maxVolunteers, status: data.status, + categoryId: data.categoryId, }); return NextResponse.json({ diff --git a/src/app/api/staff/calendar/events/route.ts b/src/app/api/staff/calendar/events/route.ts index 602d9cf..37def6f 100644 --- a/src/app/api/staff/calendar/events/route.ts +++ b/src/app/api/staff/calendar/events/route.ts @@ -32,6 +32,7 @@ const eventCreateSchema = z.object({ status: z.enum(["open", "full", "completed", "canceled"]).optional(), isRecurring: z.boolean().optional(), recurrencePattern: recurrencePatternSchema.optional(), + categoryId: z.number().int().positive().optional(), }); /** @@ -120,6 +121,7 @@ export async function POST(request: Request): Promise { maxVolunteers: data.maxVolunteers, isRecurring: data.isRecurring, recurrencePattern: data.recurrencePattern, + categoryId: data.categoryId, }); return NextResponse.json( diff --git a/src/app/api/staff/event-categories/route.ts b/src/app/api/staff/event-categories/route.ts new file mode 100644 index 0000000..e0450f5 --- /dev/null +++ b/src/app/api/staff/event-categories/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; + +import { listActiveEventCategories } from "@/services/event-categories.service"; +import handleError from "@/utils/handle-error"; +import { AuthError, authErrorResponse, requireAuth } from "@/utils/server/auth"; + +export async function GET(): Promise { + try { + const session = await requireAuth(); + if (!["staff", "admin"].includes(session.user.role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const categories = await listActiveEventCategories(); + + return NextResponse.json({ data: categories }); + } catch (error) { + if (error instanceof AuthError) return authErrorResponse(error); + return NextResponse.json({ error: handleError(error) }, { status: 500 }); + } +} diff --git a/src/app/api/staff/settings/event-categories/[id]/route.ts b/src/app/api/staff/settings/event-categories/[id]/route.ts new file mode 100644 index 0000000..a67b42c --- /dev/null +++ b/src/app/api/staff/settings/event-categories/[id]/route.ts @@ -0,0 +1,90 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { + deleteEventCategory, + updateEventCategory, +} from "@/services/event-categories.service"; +import { ConflictError, NotFoundError } from "@/utils/errors"; +import handleError from "@/utils/handle-error"; +import { AuthError, authErrorResponse, requireAuth } from "@/utils/server/auth"; +import { parseBodyOrError } from "@/utils/server/route-helpers"; +import { validateAndParseId } from "@/utils/validate-id"; + +const eventCategoryUpdateSchema = z.object({ + name: z.string().min(1, "Name is required").optional(), + isActive: z.boolean().optional(), +}); + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + try { + const session = await requireAuth(); + if (session.user.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { id } = await params; + const categoryId = validateAndParseId(id); + if (categoryId === null) { + return NextResponse.json( + { error: "Invalid event category ID" }, + { status: 400 }, + ); + } + + const parsed = await parseBodyOrError(request, eventCategoryUpdateSchema); + if ("response" in parsed) return parsed.response; + + const updated = await updateEventCategory(categoryId, parsed.data); + + return NextResponse.json({ + message: "Event category updated successfully", + data: updated, + }); + } catch (error) { + if (error instanceof AuthError) return authErrorResponse(error); + if (error instanceof NotFoundError) { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + if (error instanceof ConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + return NextResponse.json({ error: handleError(error) }, { status: 500 }); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + try { + const session = await requireAuth(); + if (session.user.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { id } = await params; + const categoryId = validateAndParseId(id); + if (categoryId === null) { + return NextResponse.json( + { error: "Invalid event category ID" }, + { status: 400 }, + ); + } + + await deleteEventCategory(categoryId); + + return NextResponse.json({ + message: "Event category deactivated successfully", + }); + } catch (error) { + if (error instanceof AuthError) return authErrorResponse(error); + if (error instanceof NotFoundError) { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + return NextResponse.json({ error: handleError(error) }, { status: 500 }); + } +} diff --git a/src/app/api/staff/settings/event-categories/route.ts b/src/app/api/staff/settings/event-categories/route.ts new file mode 100644 index 0000000..1bc8b75 --- /dev/null +++ b/src/app/api/staff/settings/event-categories/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { + createEventCategory, + listAllEventCategories, +} from "@/services/event-categories.service"; +import { ConflictError } from "@/utils/errors"; +import handleError from "@/utils/handle-error"; +import { AuthError, authErrorResponse, requireAuth } from "@/utils/server/auth"; +import { parseBodyOrError } from "@/utils/server/route-helpers"; + +const eventCategoryCreateSchema = z.object({ + name: z.string().min(1, "Name is required"), +}); + +export async function GET(): Promise { + try { + const session = await requireAuth(); + if (session.user.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const categories = await listAllEventCategories(); + + return NextResponse.json({ data: categories }); + } catch (error) { + if (error instanceof AuthError) return authErrorResponse(error); + return NextResponse.json({ error: handleError(error) }, { status: 500 }); + } +} + +export async function POST(request: Request): Promise { + try { + const session = await requireAuth(); + if (session.user.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const parsed = await parseBodyOrError(request, eventCategoryCreateSchema); + if ("response" in parsed) return parsed.response; + + const newCategory = await createEventCategory(parsed.data); + + return NextResponse.json( + { message: "Event category created successfully", data: newCategory }, + { status: 201 }, + ); + } catch (error) { + if (error instanceof AuthError) return authErrorResponse(error); + if (error instanceof ConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + return NextResponse.json({ error: handleError(error) }, { status: 500 }); + } +} diff --git a/src/app/staff/settings/event-categories/event-categories-settings-client.tsx b/src/app/staff/settings/event-categories/event-categories-settings-client.tsx new file mode 100644 index 0000000..52ab611 --- /dev/null +++ b/src/app/staff/settings/event-categories/event-categories-settings-client.tsx @@ -0,0 +1,340 @@ +"use client"; + +import AddIcon from "@mui/icons-material/Add"; +import EditIcon from "@mui/icons-material/Edit"; +import PauseCircleIcon from "@mui/icons-material/PauseCircle"; +import PlayCircleIcon from "@mui/icons-material/PlayCircle"; +import { + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { useSnackbar } from "notistack"; +import { JSX, useCallback, useEffect, useState } from "react"; + +import { ConfirmDialog } from "@/components/shared"; +import { LoadingSkeleton } from "@/components/ui"; +import { + type EventCategory, + useEventCategories, +} from "@/hooks/use-event-categories"; + +type CategoryForm = { name: string }; + +export default function EventCategoriesSettingsClient(): JSX.Element { + const { enqueueSnackbar } = useSnackbar(); + const { + allCategories, + loading, + fetchAllCategories, + createCategory: hookCreateCategory, + updateCategory: hookUpdateCategory, + deleteCategory: hookDeleteCategory, + } = useEventCategories(); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editingCategory, setEditingCategory] = useState( + null, + ); + const [form, setForm] = useState({ name: "" }); + const [saving, setSaving] = useState(false); + const [deactivateConfirm, setDeactivateConfirm] = + useState(null); + + useEffect(() => { + void fetchAllCategories(); + }, [fetchAllCategories]); + + const openAdd = useCallback(() => { + setEditingCategory(null); + setForm({ name: "" }); + setDialogOpen(true); + }, []); + + const openEdit = useCallback((category: EventCategory) => { + setEditingCategory(category); + setForm({ name: category.name }); + setDialogOpen(true); + }, []); + + const handleSave = useCallback(async () => { + if (!form.name.trim()) { + enqueueSnackbar("Name is required", { variant: "warning" }); + return; + } + + setSaving(true); + try { + if (editingCategory) { + await hookUpdateCategory(editingCategory.id, { + name: form.name.trim(), + }); + enqueueSnackbar("Event category updated successfully", { + variant: "success", + }); + } else { + await hookCreateCategory(form.name.trim()); + enqueueSnackbar("Event category created successfully", { + variant: "success", + }); + } + setDialogOpen(false); + void fetchAllCategories(); + } catch (error) { + enqueueSnackbar( + error instanceof Error + ? error.message + : "Failed to save event category", + { variant: "error" }, + ); + } finally { + setSaving(false); + } + }, [ + form, + editingCategory, + enqueueSnackbar, + hookCreateCategory, + hookUpdateCategory, + fetchAllCategories, + ]); + + const handleDeactivate = useCallback( + async (category: EventCategory) => { + try { + await hookDeleteCategory(category.id); + enqueueSnackbar("Event category deactivated", { variant: "success" }); + void fetchAllCategories(); + } catch (error) { + enqueueSnackbar( + error instanceof Error + ? error.message + : "Failed to deactivate event category", + { variant: "error" }, + ); + } finally { + setDeactivateConfirm(null); + } + }, + [enqueueSnackbar, hookDeleteCategory, fetchAllCategories], + ); + + const handleReactivate = useCallback( + async (category: EventCategory) => { + try { + await hookUpdateCategory(category.id, { isActive: true }); + enqueueSnackbar("Event category reactivated", { variant: "success" }); + void fetchAllCategories(); + } catch (error) { + enqueueSnackbar( + error instanceof Error + ? error.message + : "Failed to reactivate event category", + { variant: "error" }, + ); + } + }, + [enqueueSnackbar, hookUpdateCategory, fetchAllCategories], + ); + + return ( + + + + + Event Categories + + {!loading && ( + + )} + + + + + {loading ? ( + + ) : ( + + + + + Name + Status + + Actions + + + + + {allCategories.length === 0 ? ( + + + + No event categories yet.{" "} + + Add the first category + {" "} + to get started. + + + + ) : ( + allCategories.map((category, index) => ( + + + {category.name} + + + + + + + + openEdit(category)} + > + + + + {category.isActive ? ( + + setDeactivateConfirm(category)} + > + + + + ) : ( + + void handleReactivate(category)} + > + + + + )} + + + + )) + )} + +
+
+ )} + + {/* Add / Edit Dialog */} + setDialogOpen(false)} + maxWidth="xs" + fullWidth + > + + {editingCategory ? "Edit Event Category" : "Add Event Category"} + + + setForm({ name: e.target.value })} + fullWidth + required + autoFocus + sx={{ mt: 1 }} + /> + + + + + + + + + Deactivate {deactivateConfirm?.name}? Existing + events with this category are unaffected, but it will no longer + appear as an option when creating or editing events. + + } + confirmLabel="Deactivate" + confirmColor="error" + onConfirm={() => + deactivateConfirm && void handleDeactivate(deactivateConfirm) + } + onClose={() => setDeactivateConfirm(null)} + /> +
+ ); +} diff --git a/src/app/staff/settings/event-categories/page.tsx b/src/app/staff/settings/event-categories/page.tsx new file mode 100644 index 0000000..20faff1 --- /dev/null +++ b/src/app/staff/settings/event-categories/page.tsx @@ -0,0 +1,8 @@ +import { type ReactElement } from "react"; + +import EventCategoriesSettingsClient from "./event-categories-settings-client"; + +/** Event categories catalog management. Auth handled by settings layout. */ +export default function EventCategoriesSettingsPage(): ReactElement { + return ; +} diff --git a/src/app/volunteer/opportunities/page.tsx b/src/app/volunteer/opportunities/page.tsx index 1e6b866..caefadc 100644 --- a/src/app/volunteer/opportunities/page.tsx +++ b/src/app/volunteer/opportunities/page.tsx @@ -82,6 +82,14 @@ function OpportunityCard({ {opportunity.title} + {opportunity.categoryName && ( + + )} {opportunity.isRecurring && ( )} diff --git a/src/components/layout/settings-nav.tsx b/src/components/layout/settings-nav.tsx index 591c895..015eeaa 100644 --- a/src/components/layout/settings-nav.tsx +++ b/src/components/layout/settings-nav.tsx @@ -34,6 +34,10 @@ const sections: SettingsGroup[] = [ label: "Skills & Interests", href: "/staff/settings/skills", }, + { + label: "Event Categories", + href: "/staff/settings/event-categories", + }, ], }, { diff --git a/src/components/staff/calendar/event-detail-modal.tsx b/src/components/staff/calendar/event-detail-modal.tsx index a1397b7..55ccecc 100644 --- a/src/components/staff/calendar/event-detail-modal.tsx +++ b/src/components/staff/calendar/event-detail-modal.tsx @@ -12,6 +12,10 @@ import { DialogContent, DialogTitle, Divider, + FormControl, + InputLabel, + MenuItem, + Select, TextField, Typography, } from "@mui/material"; @@ -21,6 +25,7 @@ import { JSX, useEffect, useState } from "react"; import { EmptyState, getRsvpStatusColor, StatusBadge } from "@/components/ui"; import type { CalendarEvent } from "@/hooks/use-calendar-events"; import { useCalendarEvents } from "@/hooks/use-calendar-events"; +import { useEventCategories } from "@/hooks/use-event-categories"; import { useEventRsvps } from "@/hooks/use-event-rsvps"; import { useOpportunitySkills } from "@/hooks/use-opportunity-skills"; import type { CatalogInterest, CatalogSkill } from "@/hooks/use-skills"; @@ -81,6 +86,7 @@ export default function EventDetailModal({ loadingSkills, loadingInterests, } = useSkills(); + const { activeCategories } = useEventCategories(); const numericEventId = eventId ? Number.parseInt(eventId, 10) : null; const { @@ -112,6 +118,7 @@ export default function EventDetailModal({ const [editSelectedInterests, setEditSelectedInterests] = useState< CatalogInterest[] >([]); + const [editCategoryId, setEditCategoryId] = useState(""); const [editInitialSkillIds, setEditInitialSkillIds] = useState>( new Set(), ); @@ -169,6 +176,8 @@ export default function EventDetailModal({ setEditSelectedInterests(resolvedInterests); setEditInitialInterestIds(new Set(resolvedInterests.map((i) => i.id))); + setEditCategoryId(event.extendedProps?.categoryId ?? ""); + setMode("edit"); }; @@ -200,6 +209,7 @@ export default function EventDetailModal({ maxVolunteers: editForm.maxVolunteers ? Number.parseInt(editForm.maxVolunteers, 10) : undefined, + categoryId: editCategoryId || null, }); const latestRequirements = await refetchSkills(); @@ -400,6 +410,30 @@ export default function EventDetailModal({ + {event.extendedProps?.categoryName && ( + + + Category + + + + )} + @@ -703,6 +737,30 @@ export default function EventDetailModal({ sx={{ "& .MuiOutlinedInput-root": { borderRadius: 2 } }} /> + + Category + + + (() => ({ ...defaultFormData, @@ -102,6 +105,7 @@ export default function EventFormModal({ const [selectedInterests, setSelectedInterests] = useState( [], ); + const [selectedCategoryId, setSelectedCategoryId] = useState(""); // Sync initialDates when modal opens const handleOpen = (): void => { @@ -113,6 +117,7 @@ export default function EventFormModal({ setRecurrence(defaultRecurrence); setSelectedSkills([]); setSelectedInterests([]); + setSelectedCategoryId(""); }; const handleClose = (): void => { @@ -171,6 +176,7 @@ export default function EventFormModal({ : {}), } : undefined, + categoryId: selectedCategoryId || undefined, }); let tagError = false; @@ -315,6 +321,30 @@ export default function EventFormModal({ sx={{ "& .MuiOutlinedInput-root": { borderRadius: 2 } }} /> + + Category + + + - + + {opportunity.categoryName && ( + + )} {opportunity.maxVolunteers !== null && ( users.id, { onDelete: "restrict" }), recurrencePattern: jsonb("recurrence_pattern"), isRecurring: boolean("is_recurring").default(false).notNull(), + categoryId: integer("category_id").references(() => eventCategories.id, { + onDelete: "set null", + }), ...timestamps, }); @@ -109,6 +120,13 @@ export const volunteerHours = pgTable("volunteer_hours", { }); // Table relationships +export const eventCategoriesRelations = relations( + eventCategories, + ({ many }) => ({ + opportunities: many(opportunities), + }), +); + export const opportunitiesRelations = relations( opportunities, ({ one, many }) => ({ @@ -117,6 +135,10 @@ export const opportunitiesRelations = relations( references: [users.id], relationName: "CreatedOpportunities", }), + category: one(eventCategories, { + fields: [opportunities.categoryId], + references: [eventCategories.id], + }), requiredSkills: many(opportunityRequiredSkills), interests: many(opportunityInterests), rsvps: many(volunteerRsvps), diff --git a/src/hooks/use-calendar-events.ts b/src/hooks/use-calendar-events.ts index c2e7142..2cbdb34 100644 --- a/src/hooks/use-calendar-events.ts +++ b/src/hooks/use-calendar-events.ts @@ -17,6 +17,8 @@ export type CalendarEvent = { createdById?: number; isRecurring?: boolean; recurrencePattern?: unknown; + categoryId?: number | null; + categoryName?: string | null; }; }; @@ -36,9 +38,20 @@ type CreateEventData = { maxVolunteers?: number; isRecurring?: boolean; recurrencePattern?: RecurrencePattern; + categoryId?: number; }; -type UpdateEventData = Partial; +type UpdateEventData = { + title?: string; + description?: string; + location?: string; + startDate?: string; + endDate?: string; + maxVolunteers?: number; + isRecurring?: boolean; + recurrencePattern?: RecurrencePattern; + categoryId?: number | null; +}; export type UseCalendarEventsResult = { events: CalendarEvent[]; diff --git a/src/hooks/use-event-categories.ts b/src/hooks/use-event-categories.ts new file mode 100644 index 0000000..199009e --- /dev/null +++ b/src/hooks/use-event-categories.ts @@ -0,0 +1,141 @@ +import { useCallback, useEffect, useState } from "react"; + +import { useApiErrorHandler } from "@/hooks/use-api-error-handler"; +import { apiClient } from "@/lib/api-client"; + +type CacheEntry = { data: T; fetchedAt: number }; +const CACHE_TTL_MS = 60_000; +let activeCategoriesCache: CacheEntry | null = null; +let allCategoriesCache: CacheEntry | null = null; + +function isFresh(cache: CacheEntry | null): cache is CacheEntry { + return cache !== null && Date.now() - cache.fetchedAt < CACHE_TTL_MS; +} + +export type EventCategory = { + id: number; + name: string; + isActive: boolean; +}; + +export type UseEventCategoriesResult = { + activeCategories: EventCategory[]; + allCategories: EventCategory[]; + loading: boolean; + error: string | null; + fetchActiveCategories: () => Promise; + fetchAllCategories: () => Promise; + createCategory: (name: string) => Promise; + updateCategory: ( + id: number, + data: { name?: string; isActive?: boolean }, + ) => Promise; + deleteCategory: (id: number) => Promise; +}; + +export function useEventCategories(): UseEventCategoriesResult { + const [activeCategories, setActiveCategories] = useState([]); + const [allCategories, setAllCategories] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const handleApiError = useApiErrorHandler(setError); + + const fetchActiveCategories = useCallback(async (): Promise => { + if (isFresh(activeCategoriesCache)) { + setActiveCategories(activeCategoriesCache.data); + return; + } + setLoading(true); + try { + const json = await apiClient.get<{ data: EventCategory[] }>( + "/api/staff/event-categories", + ); + activeCategoriesCache = { data: json.data, fetchedAt: Date.now() }; + setActiveCategories(json.data); + } catch (error_) { + handleApiError(error_, "An unexpected error occurred."); + } finally { + setLoading(false); + } + }, [handleApiError]); + + const fetchAllCategories = useCallback(async (): Promise => { + if (isFresh(allCategoriesCache)) { + setAllCategories(allCategoriesCache.data); + return; + } + setLoading(true); + try { + const json = await apiClient.get<{ data: EventCategory[] }>( + "/api/staff/settings/event-categories", + ); + allCategoriesCache = { data: json.data, fetchedAt: Date.now() }; + setAllCategories(json.data); + } catch (error_) { + handleApiError(error_, "An unexpected error occurred."); + } finally { + setLoading(false); + } + }, [handleApiError]); + + const createCategory = useCallback( + async (name: string): Promise => { + try { + await apiClient.post("/api/staff/settings/event-categories", { name }); + activeCategoriesCache = null; + allCategoriesCache = null; + void fetchAllCategories(); + } catch (error_) { + handleApiError(error_, "An unexpected error occurred."); + } + }, + [fetchAllCategories, handleApiError], + ); + + const updateCategory = useCallback( + async ( + id: number, + data: { name?: string; isActive?: boolean }, + ): Promise => { + try { + await apiClient.put(`/api/staff/settings/event-categories/${id}`, data); + activeCategoriesCache = null; + allCategoriesCache = null; + void fetchAllCategories(); + } catch (error_) { + handleApiError(error_, "An unexpected error occurred."); + } + }, + [fetchAllCategories, handleApiError], + ); + + const deleteCategory = useCallback( + async (id: number): Promise => { + try { + await apiClient.delete(`/api/staff/settings/event-categories/${id}`); + activeCategoriesCache = null; + allCategoriesCache = null; + void fetchAllCategories(); + } catch (error_) { + handleApiError(error_, "An unexpected error occurred."); + } + }, + [fetchAllCategories, handleApiError], + ); + + useEffect(() => { + void fetchActiveCategories(); + }, [fetchActiveCategories]); + + return { + activeCategories, + allCategories, + loading, + error, + fetchActiveCategories, + fetchAllCategories, + createCategory, + updateCategory, + deleteCategory, + }; +} diff --git a/src/services/calendar-events.service.ts b/src/services/calendar-events.service.ts index fdeae17..c3051c6 100644 --- a/src/services/calendar-events.service.ts +++ b/src/services/calendar-events.service.ts @@ -1,7 +1,7 @@ import { and, eq, gte, inArray, lt, lte } from "drizzle-orm"; import db from "@/db"; -import { opportunities } from "@/db/schema"; +import { eventCategories, opportunities } from "@/db/schema"; import { ConflictError, NotFoundError, ValidationError } from "@/utils/errors"; export type CalendarEvent = { @@ -17,6 +17,8 @@ export type CalendarEvent = { createdById: number | null; isRecurring: boolean; recurrencePattern: unknown; + categoryId: number | null; + categoryName: string | null; }; }; @@ -29,6 +31,7 @@ export type RecurrencePattern = { function toCalendarEvent( row: typeof opportunities.$inferSelect, + categoryName: string | null = null, ): CalendarEvent { return { id: row.id.toString(), @@ -43,6 +46,8 @@ function toCalendarEvent( createdById: row.createdById, isRecurring: row.isRecurring, recurrencePattern: row.recurrencePattern, + categoryId: row.categoryId ?? null, + categoryName, }, }; } @@ -113,14 +118,32 @@ export async function listCalendarEvents( if (startDate) whereClauses.push(gte(opportunities.endDate, startDate)); if (endDate) whereClauses.push(lte(opportunities.startDate, endDate)); - const events = await (whereClauses.length > 0 + const rows = await (whereClauses.length > 0 ? db - .select() + .select({ + opportunity: opportunities, + categoryName: eventCategories.name, + }) .from(opportunities) + .leftJoin( + eventCategories, + eq(opportunities.categoryId, eventCategories.id), + ) .where(and(...whereClauses)) - : db.select().from(opportunities)); + : db + .select({ + opportunity: opportunities, + categoryName: eventCategories.name, + }) + .from(opportunities) + .leftJoin( + eventCategories, + eq(opportunities.categoryId, eventCategories.id), + )); - return events.map((e) => toCalendarEvent(e)); + return rows.map((r) => + toCalendarEvent(r.opportunity, r.categoryName ?? null), + ); } export async function createCalendarEvent(data: { @@ -134,6 +157,7 @@ export async function createCalendarEvent(data: { createdById: number; isRecurring?: boolean; recurrencePattern?: RecurrencePattern; + categoryId?: number; }): Promise { const occurrences = data.isRecurring && data.recurrencePattern @@ -158,6 +182,16 @@ export async function createCalendarEvent(data: { ); } + // Resolve category name once for the response + let categoryName: string | null = null; + if (data.categoryId) { + const catRows = await db + .select({ name: eventCategories.name }) + .from(eventCategories) + .where(eq(eventCategories.id, data.categoryId)); + categoryName = catRows[0]?.name ?? null; + } + const values = occurrences.map((occ) => ({ title: data.title, description: data.description ?? "", @@ -169,6 +203,7 @@ export async function createCalendarEvent(data: { maxVolunteers: data.maxVolunteers, isRecurring: data.isRecurring ?? false, recurrencePattern: data.recurrencePattern ?? null, + categoryId: data.categoryId ?? null, })); const rows = await db.insert(opportunities).values(values).returning(); @@ -177,7 +212,7 @@ export async function createCalendarEvent(data: { throw new Error("Failed to create event: no data returned from database"); } - return rows.map((r) => toCalendarEvent(r)); + return rows.map((r) => toCalendarEvent(r, categoryName)); } export async function updateCalendarEvent( @@ -190,6 +225,7 @@ export async function updateCalendarEvent( endDate?: Date; maxVolunteers?: number; status?: "open" | "full" | "completed" | "canceled"; + categoryId?: number | null; }, ): Promise { const existing = await db @@ -215,6 +251,7 @@ export async function updateCalendarEvent( endDate?: Date; maxVolunteers?: number | null; status?: "open" | "full" | "completed" | "canceled"; + categoryId?: number | null; updatedAt?: Date; } = { updatedAt: new Date() }; @@ -226,6 +263,7 @@ export async function updateCalendarEvent( if (data.maxVolunteers !== undefined) updateData.maxVolunteers = data.maxVolunteers; if (data.status !== undefined) updateData.status = data.status; + if ("categoryId" in data) updateData.categoryId = data.categoryId ?? null; const rows = await db .update(opportunities) @@ -233,7 +271,18 @@ export async function updateCalendarEvent( .where(eq(opportunities.id, id)) .returning(); - return toCalendarEvent(rows[0]); + // Resolve category name for the response + let categoryName: string | null = null; + const effectiveCategoryId = rows[0].categoryId; + if (effectiveCategoryId) { + const catRows = await db + .select({ name: eventCategories.name }) + .from(eventCategories) + .where(eq(eventCategories.id, effectiveCategoryId)); + categoryName = catRows[0]?.name ?? null; + } + + return toCalendarEvent(rows[0], categoryName); } export async function deleteCalendarEvent(id: number): Promise { diff --git a/src/services/event-categories.service.ts b/src/services/event-categories.service.ts new file mode 100644 index 0000000..0270315 --- /dev/null +++ b/src/services/event-categories.service.ts @@ -0,0 +1,102 @@ +import { and, eq, ne } from "drizzle-orm"; + +import db from "@/db"; +import { eventCategories } from "@/db/schema"; +import { ConflictError, NotFoundError } from "@/utils/errors"; + +export type EventCategory = typeof eventCategories.$inferSelect; + +export async function listActiveEventCategories(): Promise { + return db + .select() + .from(eventCategories) + .where(eq(eventCategories.isActive, true)) + .orderBy(eventCategories.name); +} + +export async function listAllEventCategories(): Promise { + return db.select().from(eventCategories).orderBy(eventCategories.name); +} + +export async function createEventCategory(data: { + name: string; +}): Promise { + const existing = await db + .select() + .from(eventCategories) + .where(eq(eventCategories.name, data.name)); + + if (existing.length > 0) { + throw new ConflictError("An event category with this name already exists"); + } + + const rows = await db + .insert(eventCategories) + .values({ name: data.name }) + .returning(); + + return rows[0]; +} + +export async function updateEventCategory( + id: number, + data: { name?: string; isActive?: boolean }, +): Promise { + const rows = await db + .select() + .from(eventCategories) + .where(eq(eventCategories.id, id)); + + if (rows.length === 0) { + throw new NotFoundError("Event category not found"); + } + + if (data.name !== undefined && data.name !== rows[0].name) { + const existing = await db + .select() + .from(eventCategories) + .where( + and(eq(eventCategories.name, data.name), ne(eventCategories.id, id)), + ); + + if (existing.length > 0) { + throw new ConflictError( + "An event category with this name already exists", + ); + } + } + + const updateData: { name?: string; isActive?: boolean } = {}; + if (data.name !== undefined) updateData.name = data.name; + if (data.isActive !== undefined) updateData.isActive = data.isActive; + + if (Object.keys(updateData).length > 0) { + await db + .update(eventCategories) + .set(updateData) + .where(eq(eventCategories.id, id)); + } + + const updated = await db + .select() + .from(eventCategories) + .where(eq(eventCategories.id, id)); + + return updated[0]; +} + +export async function deleteEventCategory(id: number): Promise { + const rows = await db + .select() + .from(eventCategories) + .where(eq(eventCategories.id, id)); + + if (rows.length === 0) { + throw new NotFoundError("Event category not found"); + } + + await db + .update(eventCategories) + .set({ isActive: false }) + .where(eq(eventCategories.id, id)); +} diff --git a/src/services/opportunities.service.ts b/src/services/opportunities.service.ts index 08197ac..c491a5e 100644 --- a/src/services/opportunities.service.ts +++ b/src/services/opportunities.service.ts @@ -2,6 +2,7 @@ import { and, count, eq, gt, inArray, notInArray, sql } from "drizzle-orm"; import db from "@/db"; import { + eventCategories, opportunities, opportunityInterests, opportunityRequiredSkills, @@ -30,6 +31,8 @@ export type OpportunityWithSpots = { status: string | null; maxVolunteers: number | null; isRecurring: boolean; + categoryId: number | null; + categoryName: string | null; rsvpCount: number; spotsRemaining: number | null; requiredSkills: { skillId: number; skillName: string | null }[]; @@ -76,6 +79,8 @@ export async function listOpenOpportunities( status: opportunities.status, maxVolunteers: opportunities.maxVolunteers, isRecurring: opportunities.isRecurring, + categoryId: opportunities.categoryId, + categoryName: eventCategories.name, rsvpCount: sql`COALESCE(${rsvpCountSubquery.rsvpCount}, 0)`, }) .from(opportunities) @@ -83,7 +88,7 @@ export async function listOpenOpportunities( rsvpCountSubquery, eq(opportunities.id, rsvpCountSubquery.opportunityId), ) - + .leftJoin(eventCategories, eq(opportunities.categoryId, eventCategories.id)) .$dynamic(); // Filter out full opportunities at the SQL level @@ -182,6 +187,8 @@ export async function listOpenOpportunities( const rsvpCount = Number(opp.rsvpCount); return { ...opp, + categoryId: opp.categoryId ?? null, + categoryName: opp.categoryName ?? null, rsvpCount, spotsRemaining: opp.maxVolunteers === null ? null : opp.maxVolunteers - rsvpCount, @@ -223,6 +230,8 @@ export async function getOpenOpportunityById( status: opportunities.status, maxVolunteers: opportunities.maxVolunteers, isRecurring: opportunities.isRecurring, + categoryId: opportunities.categoryId, + categoryName: eventCategories.name, rsvpCount: sql`COALESCE(${rsvpCountSubquery.rsvpCount}, 0)`, }) .from(opportunities) @@ -230,6 +239,7 @@ export async function getOpenOpportunityById( rsvpCountSubquery, eq(opportunities.id, rsvpCountSubquery.opportunityId), ) + .leftJoin(eventCategories, eq(opportunities.categoryId, eventCategories.id)) .where( and( eq(opportunities.id, id), @@ -265,6 +275,8 @@ export async function getOpenOpportunityById( return { ...opp, + categoryId: opp.categoryId ?? null, + categoryName: opp.categoryName ?? null, rsvpCount, spotsRemaining: opp.maxVolunteers === null ? null : opp.maxVolunteers - rsvpCount, diff --git a/src/services/recommendation.service.ts b/src/services/recommendation.service.ts index b174d57..090b225 100644 --- a/src/services/recommendation.service.ts +++ b/src/services/recommendation.service.ts @@ -109,6 +109,8 @@ export async function getRecommendedOpportunities( status: opp.status, maxVolunteers: opp.maxVolunteers, isRecurring: opp.isRecurring, + categoryId: opp.categoryId, + categoryName: opp.categoryName, rsvpCount: opp.rsvpCount, spotsRemaining: opp.spotsRemaining, requiredSkills: opp.requiredSkills,