diff --git a/backend/drizzle/0002_fluffy_longshot.sql b/backend/drizzle/0002_fluffy_longshot.sql new file mode 100644 index 0000000..b89093b --- /dev/null +++ b/backend/drizzle/0002_fluffy_longshot.sql @@ -0,0 +1,19 @@ +CREATE TABLE "appointments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" text NOT NULL, + "patient_file_number" text DEFAULT '' NOT NULL, + "patient_name" text NOT NULL, + "patient_initials" text NOT NULL, + "date" text NOT NULL, + "time" text NOT NULL, + "type" text NOT NULL, + "provider" text NOT NULL, + "status" text NOT NULL, + "created_by" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "appointments" ADD CONSTRAINT "appointments_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "appointments" ADD CONSTRAINT "appointments_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "appointments_org_date_idx" ON "appointments" USING btree ("organization_id","date"); \ No newline at end of file diff --git a/backend/drizzle/meta/0002_snapshot.json b/backend/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..e196420 --- /dev/null +++ b/backend/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1584 @@ +{ + "id": "6e786663-8ca4-4a3a-bc06-a0e7adbd9408", + "prevId": "0c2bcebb-972f-40e7-8f42-767236bd63ce", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "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 + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit": { + "name": "rate_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": 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 + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": 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": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_allergies": { + "name": "patient_allergies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "substance": { + "name": "substance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "allergies_patient_idx": { + "name": "allergies_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_allergies_patient_id_patients_id_fk": { + "name": "patient_allergies_patient_id_patients_id_fk", + "tableFrom": "patient_allergies", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_encounters": { + "name": "patient_encounters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "encounters_patient_idx": { + "name": "encounters_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_encounters_patient_id_patients_id_fk": { + "name": "patient_encounters_patient_id_patients_id_fk", + "tableFrom": "patient_encounters", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_labs": { + "name": "patient_labs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flag": { + "name": "flag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "labs_patient_idx": { + "name": "labs_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_labs_patient_id_patients_id_fk": { + "name": "patient_labs_patient_id_patients_id_fk", + "tableFrom": "patient_labs", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_medications": { + "name": "patient_medications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dose": { + "name": "dose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "frequency": { + "name": "frequency", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "medications_patient_idx": { + "name": "medications_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_medications_patient_id_patients_id_fk": { + "name": "patient_medications_patient_id_patients_id_fk", + "tableFrom": "patient_medications", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patients": { + "name": "patients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_number": { + "name": "file_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sex": { + "name": "sex", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pcp": { + "name": "pcp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initials": { + "name": "initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alerts": { + "name": "alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "vitals_bp": { + "name": "vitals_bp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_hr": { + "name": "vitals_hr", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_temp": { + "name": "vitals_temp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_spo2": { + "name": "vitals_spo2", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_taken_at": { + "name": "vitals_taken_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_trend": { + "name": "vitals_trend", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "lab_trend": { + "name": "lab_trend", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "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": { + "patients_org_file_uidx": { + "name": "patients_org_file_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "patients_org_idx": { + "name": "patients_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patients_organization_id_organization_id_fk": { + "name": "patients_organization_id_organization_id_fk", + "tableFrom": "patients", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "patients_created_by_user_id_fk": { + "name": "patients_created_by_user_id_fk", + "tableFrom": "patients", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_problems": { + "name": "patient_problems", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "since": { + "name": "since", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problems_patient_idx": { + "name": "problems_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_problems_patient_id_patients_id_fk": { + "name": "patient_problems_patient_id_patients_id_fk", + "tableFrom": "patient_problems", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "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": { + "notes_org_author_idx": { + "name": "notes_org_author_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notes_organization_id_organization_id_fk": { + "name": "notes_organization_id_organization_id_fk", + "tableFrom": "notes", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notes_author_id_user_id_fk": { + "name": "notes_author_id_user_id_fk", + "tableFrom": "notes", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointments": { + "name": "appointments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_initials": { + "name": "patient_initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "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": { + "appointments_org_date_idx": { + "name": "appointments_org_date_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "appointments_organization_id_organization_id_fk": { + "name": "appointments_organization_id_organization_id_fk", + "tableFrom": "appointments", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "appointments_created_by_user_id_fk": { + "name": "appointments_created_by_user_id_fk", + "tableFrom": "appointments", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index ab1f327..ebbbdc9 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1780591375930, "tag": "0001_boring_puck", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1780849813505, + "tag": "0002_fluffy_longshot", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/db/schema/appointments.ts b/backend/src/db/schema/appointments.ts new file mode 100644 index 0000000..fd73ea5 --- /dev/null +++ b/backend/src/db/schema/appointments.ts @@ -0,0 +1,35 @@ +import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; + +import type { AppointmentStatus } from "../../types/appointment.js"; +import { organization, user } from "./auth.js"; + +// One row per scheduled visit, scoped to a clinic (organization). Patient +// identity is denormalized (name/initials/file number) so the schedule renders +// without joining; `date`/`time` are stored as plain strings to match the UI's +// local-date model exactly (no timezone drift). +export const appointments = pgTable( + "appointments", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + patientFileNumber: text("patient_file_number").notNull().default(""), + patientName: text("patient_name").notNull(), + patientInitials: text("patient_initials").notNull(), + date: text("date").notNull(), // YYYY-MM-DD + time: text("time").notNull(), // HH:mm + type: text("type").notNull(), + provider: text("provider").notNull(), + status: text("status").$type().notNull(), + createdBy: text("created_by").references(() => user.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (t) => [index("appointments_org_date_idx").on(t.organizationId, t.date)], +); diff --git a/backend/src/db/schema/index.ts b/backend/src/db/schema/index.ts index 2d263dd..11bc358 100644 --- a/backend/src/db/schema/index.ts +++ b/backend/src/db/schema/index.ts @@ -1,3 +1,4 @@ export * from "./auth.js"; export * from "./patients.js"; export * from "./notes.js"; +export * from "./appointments.js"; diff --git a/backend/src/index.ts b/backend/src/index.ts index fdf50cf..7105379 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -5,6 +5,7 @@ import express from "express"; import { auth } from "./auth.js"; import { env } from "./env.js"; import { errorHandler, notFound } from "./middleware/error.js"; +import { appointmentsRouter } from "./routes/appointments.js"; import { notesRouter } from "./routes/notes.js"; import { patientsRouter } from "./routes/patients.js"; @@ -45,6 +46,7 @@ app.get("/health", (_req, res) => { app.use("/api/patients", patientsRouter); app.use("/api/notes", notesRouter); +app.use("/api/appointments", appointmentsRouter); app.use(notFound); app.use(errorHandler); @@ -54,4 +56,5 @@ app.listen(env.PORT, () => { console.log(` • auth: /api/auth/* (frontend origin: ${env.FRONTEND_URL})`); console.log(` • patients: /api/patients`); console.log(` • notes: /api/notes`); + console.log(` • appts: /api/appointments`); }); diff --git a/backend/src/lib/appointment-validation.ts b/backend/src/lib/appointment-validation.ts new file mode 100644 index 0000000..5115bb7 --- /dev/null +++ b/backend/src/lib/appointment-validation.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +// Payload accepted by POST/PUT /api/appointments. Mirrors the frontend +// `NewAppointment` shape; `status` defaults to "confirmed" on create. +export const appointmentInputSchema = z.object({ + fileNumber: z.string().trim().default(""), + name: z.string().trim().min(1, "Patient name is required.").max(200), + initials: z.string().trim().min(1).max(4), + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD."), + time: z.string().regex(/^\d{2}:\d{2}$/, "Time must be HH:mm."), + type: z.string().trim().min(1, "Type is required.").max(120), + provider: z.string().trim().min(1, "Provider is required.").max(200), + status: z + .enum(["confirmed", "checked-in", "completed", "cancelled"]) + .default("confirmed"), +}); + +export type AppointmentInput = z.infer; diff --git a/backend/src/routes/appointments.ts b/backend/src/routes/appointments.ts new file mode 100644 index 0000000..fbe465c --- /dev/null +++ b/backend/src/routes/appointments.ts @@ -0,0 +1,81 @@ +import { Router } from "express"; + +import { appointmentInputSchema } from "../lib/appointment-validation.js"; +import { HttpError } from "../lib/http-error.js"; +import { + requireAuth, + requireOrg, + requirePermission, +} from "../middleware/auth.js"; +import * as service from "../services/appointments.js"; + +export const appointmentsRouter = Router(); + +// Appointments are clinic-wide records, gated by the caller's role like patients. +appointmentsRouter.use(requireAuth, requireOrg); + +appointmentsRouter.get( + "/", + requirePermission({ appointment: ["read"] }), + async (req, res, next) => { + try { + res.json(await service.listAppointments(req.organizationId!)); + } catch (err) { + next(err); + } + }, +); + +appointmentsRouter.post( + "/", + requirePermission({ appointment: ["write"] }), + async (req, res, next) => { + try { + const input = appointmentInputSchema.parse(req.body); + const created = await service.createAppointment( + req.organizationId!, + req.user!.id, + input, + ); + res.status(201).json(created); + } catch (err) { + next(err); + } + }, +); + +appointmentsRouter.put( + "/:id", + requirePermission({ appointment: ["write"] }), + async (req, res, next) => { + try { + const input = appointmentInputSchema.parse(req.body); + const updated = await service.updateAppointment( + req.organizationId!, + req.params.id as string, + input, + ); + if (!updated) throw new HttpError(404, "Appointment not found."); + res.json(updated); + } catch (err) { + next(err); + } + }, +); + +appointmentsRouter.delete( + "/:id", + requirePermission({ appointment: ["delete"] }), + async (req, res, next) => { + try { + const ok = await service.deleteAppointment( + req.organizationId!, + req.params.id as string, + ); + if (!ok) throw new HttpError(404, "Appointment not found."); + res.status(204).end(); + } catch (err) { + next(err); + } + }, +); diff --git a/backend/src/services/appointments.ts b/backend/src/services/appointments.ts new file mode 100644 index 0000000..22d3ef2 --- /dev/null +++ b/backend/src/services/appointments.ts @@ -0,0 +1,94 @@ +import { and, asc, eq } from "drizzle-orm"; + +import { db } from "../db/index.js"; +import { appointments } from "../db/schema/appointments.js"; +import type { AppointmentInput } from "../lib/appointment-validation.js"; +import type { Appointment } from "../types/appointment.js"; + +type AppointmentRow = typeof appointments.$inferSelect; + +// Postgres throws on a malformed uuid; treat non-uuid ids as "not found". +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function toAppointment(row: AppointmentRow): Appointment { + return { + id: row.id, + fileNumber: row.patientFileNumber, + name: row.patientName, + initials: row.patientInitials, + date: row.date, + time: row.time, + type: row.type, + provider: row.provider, + status: row.status, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function columns(orgId: string, input: AppointmentInput, createdBy?: string) { + return { + organizationId: orgId, + patientFileNumber: input.fileNumber, + patientName: input.name, + patientInitials: input.initials, + date: input.date, + time: input.time, + type: input.type, + provider: input.provider, + status: input.status, + ...(createdBy ? { createdBy } : {}), + }; +} + +export async function listAppointments(orgId: string): Promise { + const rows = await db + .select() + .from(appointments) + .where(eq(appointments.organizationId, orgId)) + .orderBy(asc(appointments.date), asc(appointments.time)); + return rows.map(toAppointment); +} + +export async function createAppointment( + orgId: string, + userId: string, + input: AppointmentInput, +): Promise { + const [row] = await db + .insert(appointments) + .values(columns(orgId, input, userId)) + .returning(); + return toAppointment(row!); +} + +export async function updateAppointment( + orgId: string, + id: string, + input: AppointmentInput, +): Promise { + if (!UUID_RE.test(id)) return null; + const [row] = await db + .update(appointments) + .set(columns(orgId, input)) + .where( + and(eq(appointments.id, id), eq(appointments.organizationId, orgId)), + ) + .returning(); + return row ? toAppointment(row) : null; +} + +export async function deleteAppointment( + orgId: string, + id: string, +): Promise { + if (!UUID_RE.test(id)) return false; + const deleted = await db + .delete(appointments) + .where( + and(eq(appointments.id, id), eq(appointments.organizationId, orgId)), + ) + .returning({ id: appointments.id }); + return deleted.length > 0; +} diff --git a/backend/src/types/appointment.ts b/backend/src/types/appointment.ts new file mode 100644 index 0000000..9aca9cd --- /dev/null +++ b/backend/src/types/appointment.ts @@ -0,0 +1,22 @@ +// The canonical Appointment shape returned by the API. Mirrors the frontend +// `lib/appointments.ts` Appointment type. Patient fields are denormalized for +// display; `fileNumber` links back to a patient record when set. +export type AppointmentStatus = + | "confirmed" + | "checked-in" + | "completed" + | "cancelled"; + +export type Appointment = { + id: string; + fileNumber: string; + name: string; + initials: string; + date: string; // ISO YYYY-MM-DD + time: string; // HH:mm + type: string; + provider: string; + status: AppointmentStatus; + createdAt: string; + updatedAt: string; +}; diff --git a/frontend/components/appointments/add-appointment-dialog.tsx b/frontend/components/appointments/add-appointment-dialog.tsx index 7176d35..1b1d8f1 100644 --- a/frontend/components/appointments/add-appointment-dialog.tsx +++ b/frontend/components/appointments/add-appointment-dialog.tsx @@ -58,8 +58,8 @@ function Field({ label, children }: { label: string; children: ReactNode }) { } // Compact "New appointment" dialog. The patient is chosen via a quick search by -// name or file number; the rest is the slot. Appointments are mock-only, so a -// confirmed entry is handed back to the page via onAdd. +// name or file number; the rest is the slot. The new entry is handed back to the +// page via onAdd, which persists it through the appointments API. export function AddAppointmentDialog({ open, onOpenChange, diff --git a/frontend/components/appointments/appointments-view.tsx b/frontend/components/appointments/appointments-view.tsx index 56ef2ed..b781dc9 100644 --- a/frontend/components/appointments/appointments-view.tsx +++ b/frontend/components/appointments/appointments-view.tsx @@ -9,7 +9,7 @@ import { Stethoscope, Users, } from "lucide-react"; -import { type ReactNode, useMemo, useState } from "react"; +import { type ReactNode, useEffect, useMemo, useState } from "react"; import { AddAppointmentDialog, @@ -21,25 +21,34 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { + type Appointment, + createAppointment, + listAppointments, +} from "@/lib/appointments"; +import { notify } from "@/lib/toast"; -// All figures here are mock/placeholder data — there is no scheduling backend. -// They illustrate the Appointments & Schedule layout. +export type { Appointment } from "@/lib/appointments"; -// Anchor "today" to a fixed date so the mock copy ("Wednesday, June 5") lines up -// across the page, the calendar dialog, and the add dialog. ISO YYYY-MM-DD. -export const TODAY = "2026-06-05"; +// Local-date ISO key (avoids UTC drift from toISOString). +const keyOf = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( + d.getDate(), + ).padStart(2, "0")}`; -type ApptStatus = "confirmed" | "checked-in" | "completed" | "cancelled"; +// "Today" is the real current date — appointments are persisted, not seeded, so +// the schedule and calendar anchor to now. ISO YYYY-MM-DD. +export const TODAY = keyOf(new Date()); -export type Appointment = { - date: string; // ISO YYYY-MM-DD - time: string; - name: string; - initials: string; - type: string; - provider: string; - status: ApptStatus; -}; +// Sunday-anchored week window [start, end] as ISO keys, for the "This week" KPI. +function weekRange(): { start: string; end: string } { + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - now.getDay()); + const end = new Date(start.getFullYear(), start.getMonth(), start.getDate() + 6); + return { start: keyOf(start), end: keyOf(end) }; +} + +type ApptStatus = Appointment["status"]; const statusVariant: Record< ApptStatus, @@ -58,116 +67,6 @@ const statusLabel: Record = { cancelled: "Cancelled", }; -const kpis = [ - { label: "Today", value: "12", icon: CalendarClock }, - { label: "This week", value: "146", icon: Users }, - { label: "Avg. visit", value: "22 min", icon: Clock }, - { label: "Utilization", value: "87%", icon: Stethoscope }, -]; - -// Mock schedule spread across June 2026 so the month calendar looks populated. -const seed: Appointment[] = [ - { - date: TODAY, - time: "09:00", - name: "Amina Yusuf", - initials: "AY", - type: "Follow-up", - provider: "Dr. Okafor", - status: "completed", - }, - { - date: TODAY, - time: "09:30", - name: "Daniel Mensah", - initials: "DM", - type: "New patient", - provider: "Dr. Okafor", - status: "checked-in", - }, - { - date: TODAY, - time: "10:15", - name: "Leila Haddad", - initials: "LH", - type: "Lab review", - provider: "Dr. Stein", - status: "confirmed", - }, - { - date: TODAY, - time: "11:00", - name: "Carlos Rivera", - initials: "CR", - type: "Consultation", - provider: "Dr. Okafor", - status: "confirmed", - }, - { - date: TODAY, - time: "13:30", - name: "Priya Nair", - initials: "PN", - type: "Follow-up", - provider: "Dr. Stein", - status: "cancelled", - }, - { - date: TODAY, - time: "14:45", - name: "Tom Becker", - initials: "TB", - type: "Vaccination", - provider: "Dr. Okafor", - status: "confirmed", - }, - { - date: "2026-06-06", - time: "08:45", - name: "Grace Lin", - initials: "GL", - type: "Follow-up", - provider: "Dr. Stein", - status: "confirmed", - }, - { - date: "2026-06-06", - time: "10:00", - name: "Omar Farouk", - initials: "OF", - type: "New patient", - provider: "Dr. Okafor", - status: "confirmed", - }, - { - date: "2026-06-09", - time: "11:30", - name: "Sofia Marin", - initials: "SM", - type: "Consultation", - provider: "Dr. Stein", - status: "confirmed", - }, - { - date: "2026-06-12", - time: "15:00", - name: "Henry Adeyemi", - initials: "HA", - type: "Lab review", - provider: "Dr. Okafor", - status: "confirmed", - }, - { - date: "2026-06-18", - time: "09:15", - name: "Nadia Petrova", - initials: "NP", - type: "Follow-up", - provider: "Dr. Stein", - status: "confirmed", - }, -]; - // "2026-06-05" -> "Wednesday, June 5" function formatDayKey(key: string): string { return new Date(`${key}T00:00:00`).toLocaleDateString("en-US", { @@ -231,7 +130,7 @@ export function ScheduleList({ items }: { items: Appointment[] }) { return (
{items.map((appt) => ( - + ))}
); @@ -262,25 +161,61 @@ function Section({ export function AppointmentsView() { const [addOpen, setAddOpen] = useState(false); const [calendarOpen, setCalendarOpen] = useState(false); - const [appointments, setAppointments] = useState(seed); + const [appointments, setAppointments] = useState([]); const [query, setQuery] = useState(""); - // Insert a new (mock) appointment at the date/time chosen in the dialog. - const addAppointment = (appt: NewAppointment) => { - setAppointments((prev) => [ - ...prev, - { - date: appt.date, - time: appt.time, + useEffect(() => { + let active = true; + listAppointments() + .then((data) => { + if (active) setAppointments(data); + }) + .catch(() => { + /* api-client redirects on 401; otherwise leave the list empty */ + }); + return () => { + active = false; + }; + }, []); + + // Persist a new appointment, then add the saved record to the list. + const addAppointment = async (appt: NewAppointment) => { + try { + const created = await createAppointment({ + fileNumber: appt.fileNumber, name: appt.name, initials: appt.initials, + date: appt.date, + time: appt.time, type: appt.type, provider: appt.provider, - status: "confirmed" as const, - }, - ]); + }); + setAppointments((prev) => [...prev, created]); + } catch { + notify.error("Couldn't add appointment", "Please try again."); + } }; + const kpis = useMemo(() => { + const { start, end } = weekRange(); + const today = appointments.filter((a) => a.date === TODAY); + const week = appointments.filter((a) => a.date >= start && a.date <= end); + return [ + { label: "Today", value: String(today.length), icon: CalendarClock }, + { label: "This week", value: String(week.length), icon: Users }, + { + label: "Checked in", + value: String(today.filter((a) => a.status === "checked-in").length), + icon: Stethoscope, + }, + { + label: "Completed", + value: String(today.filter((a) => a.status === "completed").length), + icon: Clock, + }, + ]; + }, [appointments]); + const todayItems = useMemo( () => appointments.filter((a) => a.date === TODAY).sort(byTime), [appointments], @@ -324,7 +259,7 @@ export function AppointmentsView() { Appointments & Schedule

- Today's clinic schedule and what's coming up. Sample data. + Today's clinic schedule and what's coming up.

diff --git a/frontend/components/appointments/calendar-dialog.tsx b/frontend/components/appointments/calendar-dialog.tsx index 938c2e1..b1f1dc3 100644 --- a/frontend/components/appointments/calendar-dialog.tsx +++ b/frontend/components/appointments/calendar-dialog.tsx @@ -47,7 +47,7 @@ const chipClass: Record = { // A Google-Calendar-style month grid in a dialog: a 6×7 grid of day cells with // color-coded event chips; navigate months and click a day to list its -// appointments. Mock-only — there's no per-date scheduling backend. +// appointments. Reads the appointments passed down from the page. export function CalendarDialog({ open, onOpenChange, diff --git a/frontend/lib/appointments.ts b/frontend/lib/appointments.ts new file mode 100644 index 0000000..739ffcb --- /dev/null +++ b/frontend/lib/appointments.ts @@ -0,0 +1,61 @@ +import { apiFetch } from "@/lib/api-client"; + +// An appointment. Mirrors the backend `src/types/appointment.ts`. Scoped to the +// active clinic. `fileNumber` links back to a patient record when set. +export type AppointmentStatus = + | "confirmed" + | "checked-in" + | "completed" + | "cancelled"; + +export type Appointment = { + id: string; + fileNumber: string; + name: string; + initials: string; + date: string; // ISO YYYY-MM-DD + time: string; // HH:mm + type: string; + provider: string; + status: AppointmentStatus; + createdAt: string; + updatedAt: string; +}; + +export type AppointmentInput = { + fileNumber: string; + name: string; + initials: string; + date: string; + time: string; + type: string; + provider: string; + status?: AppointmentStatus; +}; + +export function listAppointments(): Promise { + return apiFetch("/api/appointments"); +} + +export function createAppointment( + input: AppointmentInput, +): Promise { + return apiFetch("/api/appointments", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function updateAppointment( + id: string, + input: AppointmentInput, +): Promise { + return apiFetch(`/api/appointments/${id}`, { + method: "PUT", + body: JSON.stringify(input), + }); +} + +export function deleteAppointment(id: string): Promise { + return apiFetch(`/api/appointments/${id}`, { method: "DELETE" }); +}