diff --git a/.idea/misc.xml b/.idea/misc.xml
index ed86406..db93c94 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,8 +1,3 @@
-
-
-
-
-
diff --git a/Dockerfile b/Dockerfile
index 96bdef0..0daeb3d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -11,6 +11,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libzip-dev \
git \
unzip \
+ locales \
&& rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-configure gd --with-freetype --with-jpeg && \
@@ -46,7 +47,7 @@ RUN composer require drupal/core-composer-scaffold:^11 --no-update
RUN composer require drush/drush:"^13 || ^14" --no-update
# Extra
-RUN composer require drupal/webform drupal/symfony_mailer --no-update
+RUN composer require drupal/webform drupal/symfony_mailer drupal/claro_compact --no-update
#######
@@ -57,6 +58,10 @@ RUN composer install --no-dev --optimize-autoloader --no-interaction
COPY web/sites/default/settings.php web/sites/default/settings.php
COPY web/sites/default/files/ web/sites/default/files/
COPY web/modules/custom/ web/modules/custom/
+
+ARG FULLCALENDAR_VERSION=6.1.15
+RUN curl -fsSL "https://cdn.jsdelivr.net/npm/fullcalendar@${FULLCALENDAR_VERSION}/index.global.min.js" \
+ -o web/modules/custom/riverside_pt/js/fullcalendar.min.js
COPY config/sync/ config/sync/
# Debian nginx runs as www-data (matches php-fpm), config in conf.d/
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..1691e5d
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,8 @@
+shell:
+ docker compose exec app bash
+
+drush:
+ docker compose exec app drush $(filter-out $@,$(MAKECMDGOALS))
+
+%:
+ @:
diff --git a/README.md b/README.md
index b08f89e..41ae4fc 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,24 @@
-How to run:
+# Riverside Patient Tracker
-```
+A Drupal-based appointment scheduling site for booking sessions between patients and practitioners.
+
+## Running locally
+
+```sh
docker compose up --build
```
-Username, password: admin / admin
+Admin login: `admin` / `admin` at `/user/login`
+
+## Makefile commands
+
+```sh
+make shell # open a bash shell in the app container
+make drush # run any drush command, e.g. make drush cr
+```
+
+## Modules
+
+- **FullCalendar View** — interactive appointment calendar
+- **Webform** — patient booking forms
+- **Symfony Mailer** — transactional email
diff --git a/docker-compose.yml b/docker-compose.yml
index d70a07a..1787e9f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -28,9 +28,12 @@ services:
POSTGRES_PASSWORD: drupal
volumes:
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
+ - postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U drupal -d drupal"]
interval: 5s
timeout: 5s
retries: 20
- # No named volume for data = fully ephemeral (recreated from init.sql on every `docker compose up` after `down`)
+
+volumes:
+ postgres_data:
diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh
index 9b3fc08..6a9aaa4 100644
--- a/docker/php/entrypoint.sh
+++ b/docker/php/entrypoint.sh
@@ -26,7 +26,7 @@ if [ "$HAS_TABLES" = "1" ]; then
echo "[entrypoint] No config to import, continuing."
else
echo "[entrypoint] Fresh database, installing Drupal..."
- $DRUSH site:install minimal \
+ $DRUSH site:install standard \
--site-name="${SITE_NAME:-Portfolio}" \
--account-name=admin \
--account-pass="${ADMIN_PASS:-admin}" \
@@ -37,13 +37,14 @@ else
$DRUSH en -y views views_ui field_ui text options link datetime
$DRUSH en -y webform webform_ui
$DRUSH en -y symfony_mailer
+
$DRUSH en -y riverside_pt
echo "[entrypoint] Modules enabled."
echo "[entrypoint] Setting themes..."
- $DRUSH theme:enable olivero claro
- $DRUSH config:set system.theme default olivero -y
- $DRUSH config:set system.theme admin claro -y
+ $DRUSH theme:enable olivero claro_compact
+ $DRUSH config:set system.theme default olivero -y
+ $DRUSH config:set system.theme admin claro_compact -y
echo "[entrypoint] Themes set."
if ls /var/www/html/config/sync/*.yml >/dev/null 2>&1; then
diff --git a/web/modules/custom/riverside_pt/js/calendar.js b/web/modules/custom/riverside_pt/js/calendar.js
new file mode 100644
index 0000000..daee8e6
--- /dev/null
+++ b/web/modules/custom/riverside_pt/js/calendar.js
@@ -0,0 +1,13 @@
+(function (drupalSettings) {
+ document.addEventListener('DOMContentLoaded', function () {
+ const el = document.getElementById('riverside-calendar');
+ if (!el) return;
+
+ const calendar = new FullCalendar.Calendar(el, {
+ initialView: 'timeGridWeek',
+ events: drupalSettings.riversidePt.eventsUrl,
+ });
+
+ calendar.render();
+ });
+})(drupalSettings);
diff --git a/web/modules/custom/riverside_pt/riverside_pt.install b/web/modules/custom/riverside_pt/riverside_pt.install
index e95f14e..1bdbc14 100644
--- a/web/modules/custom/riverside_pt/riverside_pt.install
+++ b/web/modules/custom/riverside_pt/riverside_pt.install
@@ -1,16 +1,143 @@
'appointment',
- 'name' => 'Appointment',
- 'description' => 'A booking between a Patient and a Provider at a particular time.',
- 'new_revision' => FALSE,
- 'preview_mode' => DRUPAL_DISABLED,
- 'display_submitted' => FALSE,
- ]);
- $type->save();
-}
+ array_walk([
+ // Appointment
+ NodeType::create([
+ 'type' => 'appointment',
+ 'name' => 'Appointment',
+ 'description' => 'A booking between a Patient and a Provider at a particular time.',
+ 'new_revision' => FALSE,
+ 'display_submitted' => FALSE,
+ ]),
+ FieldStorageConfig::create([
+ 'field_name' => 'field_appointment_date',
+ 'entity_type' => 'node',
+ 'type' => 'datetime',
+ 'settings' => ['datetime_type' => 'datetime'],
+ ]),
+ FieldStorageConfig::create([
+ 'field_name' => 'field_duration_minutes',
+ 'entity_type' => 'node',
+ 'type' => 'integer',
+ ]),
+ FieldStorageConfig::create([
+ 'field_name' => 'field_service_type',
+ 'entity_type' => 'node',
+ 'type' => 'list_string',
+ 'settings' => [
+ 'allowed_values' => [
+ 'diagnostic' => 'Diagnostic',
+ 'sports_rehab' => 'Sports Rehab',
+ 'pre_post_surgical_rehab' => 'Pre/Post-Surgical Rehab',
+ 'neurological_pt' => 'Neurological PT',
+ ],
+ ],
+ ]),
+ FieldStorageConfig::create([
+ 'field_name' => 'field_provider',
+ 'entity_type' => 'node',
+ 'type' => 'entity_reference',
+ 'settings' => ['target_type' => 'user'],
+ ]),
+ FieldConfig::create([
+ 'field_name' => 'field_appointment_date',
+ 'entity_type' => 'node',
+ 'bundle' => 'appointment',
+ 'label' => 'Appointment Date',
+ 'required' => TRUE,
+ ]),
+ FieldConfig::create([
+ 'field_name' => 'field_duration_minutes',
+ 'entity_type' => 'node',
+ 'bundle' => 'appointment',
+ 'label' => 'Duration (Minutes)',
+ 'required' => TRUE,
+ ]),
+ FieldConfig::create([
+ 'field_name' => 'field_service_type',
+ 'entity_type' => 'node',
+ 'bundle' => 'appointment',
+ 'label' => 'Service Type',
+ 'required' => TRUE,
+ ]),
+ FieldConfig::create([
+ 'field_name' => 'field_provider',
+ 'entity_type' => 'node',
+ 'bundle' => 'appointment',
+ 'label' => 'Provider',
+ 'required' => TRUE,
+ 'settings' => [
+ 'handler' => 'default:user',
+ 'handler_settings' => [
+ 'filter' => [
+ 'type' => '_role',
+ 'role' => ['provider' => 'provider'],
+ ],
+ ],
+ ],
+ ]),
+ // Provider
+ Role::create([
+ 'id' => 'provider',
+ 'label' => 'Provider',
+ ]),
+
+ // Provider Availability
+ NodeType::create([
+ 'type' => 'provider_availability',
+ 'name' => 'Provider Availability',
+ 'description' => 'A window of time during which a Provider is available for appointments.',
+ 'new_revision' => FALSE,
+ 'display_submitted' => FALSE,
+ ]),
+ FieldStorageConfig::create([
+ 'field_name' => 'field_start_datetime',
+ 'entity_type' => 'node',
+ 'type' => 'datetime',
+ 'settings' => ['datetime_type' => 'datetime'],
+ ]),
+ FieldStorageConfig::create([
+ 'field_name' => 'field_end_datetime',
+ 'entity_type' => 'node',
+ 'type' => 'datetime',
+ 'settings' => ['datetime_type' => 'datetime'],
+ ]),
+ FieldConfig::create([
+ 'field_name' => 'field_provider',
+ 'entity_type' => 'node',
+ 'bundle' => 'provider_availability',
+ 'label' => 'Provider',
+ 'required' => TRUE,
+ 'settings' => [
+ 'handler' => 'default:user',
+ 'handler_settings' => [
+ 'filter' => [
+ 'type' => '_role',
+ 'role' => ['provider' => 'provider'],
+ ],
+ ],
+ ],
+ ]),
+ FieldConfig::create([
+ 'field_name' => 'field_start_datetime',
+ 'entity_type' => 'node',
+ 'bundle' => 'provider_availability',
+ 'label' => 'Start',
+ 'required' => TRUE,
+ ]),
+ FieldConfig::create([
+ 'field_name' => 'field_end_datetime',
+ 'entity_type' => 'node',
+ 'bundle' => 'provider_availability',
+ 'label' => 'End',
+ 'required' => TRUE,
+ ]),
+ ], fn($entity) => $entity->save());
+}
diff --git a/web/modules/custom/riverside_pt/riverside_pt.libraries.yml b/web/modules/custom/riverside_pt/riverside_pt.libraries.yml
new file mode 100644
index 0000000..aa3fee1
--- /dev/null
+++ b/web/modules/custom/riverside_pt/riverside_pt.libraries.yml
@@ -0,0 +1,6 @@
+schedule:
+ js:
+ js/fullcalendar.min.js: { minified: true }
+ js/calendar.js: {}
+ dependencies:
+ - core/drupalSettings
diff --git a/web/modules/custom/riverside_pt/riverside_pt.routing.yml b/web/modules/custom/riverside_pt/riverside_pt.routing.yml
new file mode 100644
index 0000000..295bf00
--- /dev/null
+++ b/web/modules/custom/riverside_pt/riverside_pt.routing.yml
@@ -0,0 +1,17 @@
+riverside_pt.schedule:
+ path: '/schedule'
+ defaults:
+ _controller: '\Drupal\riverside_pt\Controller\ScheduleController::page'
+ _title: 'Schedule'
+ requirements:
+ _permission: 'access content'
+
+riverside_pt.schedule_events:
+ path: '/schedule/events'
+ defaults:
+ _controller: '\Drupal\riverside_pt\Controller\ScheduleController::events'
+ requirements:
+ _permission: 'access content'
+ options:
+ _auth:
+ - cookie
diff --git a/web/modules/custom/riverside_pt/src/Controller/ScheduleController.php b/web/modules/custom/riverside_pt/src/Controller/ScheduleController.php
new file mode 100644
index 0000000..c05b862
--- /dev/null
+++ b/web/modules/custom/riverside_pt/src/Controller/ScheduleController.php
@@ -0,0 +1,58 @@
+ 'html_tag',
+ '#tag' => 'div',
+ '#attributes' => ['id' => 'riverside-calendar'],
+ '#attached' => [
+ 'library' => ['riverside_pt/schedule'],
+ 'drupalSettings' => [
+ 'riversidePt' => [
+ 'eventsUrl' => Url::fromRoute('riverside_pt.schedule_events')->toString(),
+ ],
+ ],
+ ],
+ ];
+ }
+
+ public function events(Request $request): JsonResponse {
+ $start = $request->query->get('start');
+ $end = $request->query->get('end');
+
+ $query = \Drupal::entityQuery('node')
+ ->condition('type', 'provider_availability')
+ ->condition('status', 1)
+ ->accessCheck(TRUE);
+
+ if ($start) {
+ $query->condition('field_end_datetime', $start, '>=');
+ }
+ if ($end) {
+ $query->condition('field_start_datetime', $end, '<=');
+ }
+
+ $nids = $query->execute();
+ $nodes = Node::loadMultiple($nids);
+
+ $events = array_map(fn(Node $node) => [
+ 'id' => $node->id(),
+ 'title' => $node->field_provider->entity?->getDisplayName() ?? 'Provider',
+ 'start' => $node->field_start_datetime->value,
+ 'end' => $node->field_end_datetime->value,
+ ], $nodes);
+
+ return new JsonResponse(array_values($events));
+ }
+
+}