diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a7a5146..467c9c4d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to a versioning scheme of `year.minor.patch`.
+## [2025.2.0] - 2025-11-15
+
+### Added
+
+- **Admin Check-In DateTime Change**: Admins can now change registration date/time during the check-in review process
+ - New "Change Date/Time" button on admin check-in review page
+ - Date/Time modal component with accordion-grouped available time slots
+ - Real-time availability display showing remaining spots for each time slot
+ - Confirmation dialog when changing from existing reservation
+ - Automatic email notification sent to registrant with updated date/time
+- **Enhanced Firebase Functions**: Updated `changeRegistrationDateTime` function to support admin operations
+ - Admins can change date/time for any registration (with proper permission checks)
+ - Non-admin users can only change their own registrations
+ - Prevents changes after check-in to maintain data integrity
+- **Program Year Configuration**: Added `PROGRAM_YEAR` injection token to admin application
+ - Centralized program year management (set to 2025)
+ - Used by date/time selection to filter available slots
+- **Firebase Hosting Rewrites**: Added `changeRegistrationDateTime` function to admin hosting configuration
+
+### Changed
+
+- **Review Page UI**: Updated check-in review page layout
+ - Delete button now only shows when registration exists
+ - Improved button text from "Cancel Reservation" to "Delete" for clarity
+ - Date/time display now includes change button when reservation exists
+
+### Fixed
+
+- Removed duplicate batch.set call in `changeRegistrationDateTime` function
+
## [2025.1.0] - 2025-11-15
### Added
diff --git a/firebase.json b/firebase.json
index 0b0a5f03..29ba92c3 100644
--- a/firebase.json
+++ b/firebase.json
@@ -108,6 +108,10 @@
"source": "/callableResendRegistrationEmail",
"function": "callableResendRegistrationEmail"
},
+ {
+ "source": "/changeRegistrationDateTime",
+ "function": "changeRegistrationDateTime"
+ },
{
"source": "**",
"destination": "/index.html"
diff --git a/package.json b/package.json
index d1c6ccee..05c875b1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "santasworkshop",
- "version": "2025.1.0",
+ "version": "2025.2.0",
"author": "Joel Meaders",
"private": true,
"scripts": {
diff --git a/santashop-admin/package.json b/santashop-admin/package.json
index afa5693c..4262fa24 100644
--- a/santashop-admin/package.json
+++ b/santashop-admin/package.json
@@ -1,6 +1,6 @@
{
"name": "@santashop/admin",
- "version": "2025.1.0",
+ "version": "2025.2.0",
"description": "",
"scripts": {
"lint": "ng lint santashop-admin --fix --cache",
diff --git a/santashop-admin/src/app/pages/admin/checkin/review/review.page.html b/santashop-admin/src/app/pages/admin/checkin/review/review.page.html
index ae0379d8..98c3a05a 100644
--- a/santashop-admin/src/app/pages/admin/checkin/review/review.page.html
+++ b/santashop-admin/src/app/pages/admin/checkin/review/review.page.html
@@ -1,9 +1,10 @@
- @if (allowCancelRegistration$ | async) {
+ @if (registration$ | async; as registration) { @if (allowCancelRegistration$
+ | async) {
- Cancel Reservation
+ Delete
- }
+ } }
@@ -37,6 +38,15 @@
Reservation: {{ registration.dateTimeSlot?.dateTime
| date:'medium' }}
+
+ Change Date/Time
+
}
diff --git a/santashop-admin/src/app/pages/admin/checkin/review/review.page.ts b/santashop-admin/src/app/pages/admin/checkin/review/review.page.ts
index 13409760..616815e7 100644
--- a/santashop-admin/src/app/pages/admin/checkin/review/review.page.ts
+++ b/santashop-admin/src/app/pages/admin/checkin/review/review.page.ts
@@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import {
AlertController,
+ ModalController,
IonContent,
IonList,
IonListHeader,
@@ -13,7 +14,7 @@ import {
IonIcon,
IonButton,
} from '@ionic/angular/standalone';
-import { Child, Registration } from '@santashop/models';
+import { Child, DateTimeSlot, Registration } from '@santashop/models';
import {
catchError,
firstValueFrom,
@@ -31,6 +32,7 @@ import { LookupService } from '../../../../shared/services/lookup.service';
import { HeaderComponent } from '../../../../shared/components/header/header.component';
import { AsyncPipe, DatePipe } from '@angular/common';
import { ManageChildrenComponent } from '../../../../shared/components/manage-children/manage-children.component';
+import { DateTimeModalComponent } from '../../../../shared/components/date-time-modal/date-time-modal.component';
import { addIcons } from 'ionicons';
import { checkmarkCircle } from 'ionicons/icons';
import { Functions, httpsCallable } from '@angular/fire/functions';
@@ -64,6 +66,7 @@ export class ReviewPage {
private readonly checkinService = inject(CheckInService);
private readonly appStateService = inject(AppStateService);
private readonly alertController = inject(AlertController);
+ private readonly modalController = inject(ModalController);
private readonly functions = inject(Functions);
private readonly router = inject(Router);
@@ -96,6 +99,18 @@ export class ReviewPage {
'undoRegistration',
)(registration);
+ private readonly changeRegistrationDateTimeFn = (
+ newDateTimeSlot: DateTimeSlot,
+ registrationUid?: string,
+ ): Promise> =>
+ httpsCallable<
+ { newDateTimeSlot: DateTimeSlot; registrationUid?: string },
+ boolean
+ >(
+ this.functions,
+ 'changeRegistrationDateTime',
+ )({ newDateTimeSlot, registrationUid });
+
protected readonly setRegistrationSubscription = this.lookupRegistration$
.pipe(
tap((registration) => {
@@ -173,7 +188,6 @@ export class ReviewPage {
registration?.children?.push(child);
this.checkinContext.setRegistration(registration);
}
-
public async addChild(child: Child): Promise {
const registration = await firstValueFrom(this.registration$);
if (!registration) return;
@@ -182,10 +196,57 @@ export class ReviewPage {
this.checkinContext.setRegistration(registration);
}
+ public async editDateTime(): Promise {
+ const registration = await firstValueFrom(this.registration$);
+ if (!registration?.dateTimeSlot) return;
+
+ const currentSlot = {
+ id: registration.dateTimeSlot.id,
+ dateTime: registration.dateTimeSlot.dateTime,
+ } as DateTimeSlot;
+
+ const modal = await this.modalController.create({
+ component: DateTimeModalComponent,
+ componentProps: {
+ currentSlot,
+ },
+ });
+
+ await modal.present();
+ const result = await modal.onDidDismiss();
+
+ if (result.data !== undefined) {
+ if (result.data) {
+ try {
+ await this.changeRegistrationDateTimeFn(
+ result.data,
+ registration.uid,
+ );
+ registration.dateTimeSlot = {
+ dateTime: result.data.dateTime,
+ id: result.data.id,
+ };
+ this.checkinContext.setRegistration(registration);
+ this.wasEdited = true;
+ } catch (error: unknown) {
+ const err = error as { message?: string };
+ const alert = await this.alertController.create({
+ header: 'Error changing date/time',
+ message: err.message ?? String(error),
+ });
+ await alert.present();
+ }
+ } else {
+ delete registration.dateTimeSlot;
+ this.checkinContext.setRegistration(registration);
+ this.wasEdited = true;
+ }
+ }
+ }
+
public async checkIn(): Promise {
const registration = await firstValueFrom(this.registration$);
if (!registration) return;
-
try {
const result: number = await this.checkinService.checkIn(
registration,
diff --git a/santashop-admin/src/app/shared/components/date-time-modal/date-time-modal.component.html b/santashop-admin/src/app/shared/components/date-time-modal/date-time-modal.component.html
new file mode 100644
index 00000000..a4a909ed
--- /dev/null
+++ b/santashop-admin/src/app/shared/components/date-time-modal/date-time-modal.component.html
@@ -0,0 +1,89 @@
+
+
+ Choose Date & Time
+
+ Cancel
+
+
+
+
+
+
+ @if (currentSlot) {
+
+
+
+ Current Date/Time
+
+
+
+
+ {{ currentSlot.dateTime | date: 'MMMM d' : 'MST' }},
+ {{ currentSlot.dateTime | timeSlot: 'MST' }}
+
+
+
+ }
+
+
+ Available Dates/Times
+
+
+
+ @if (availableDays?.length) {
+
+ @for (day of availableDays; track day) {
+
+
+
+ {{ day | date: 'EEEE, MMMM d' }}
+
+
+
+ @for (
+ slot of availableSlotsByDay$(day) | async;
+ track slot
+ ) {
+
+
+ {{
+ slot.dateTime | timeSlot: 'MST'
+ }}
+
+
+ {{ spotsRemaining(slot) }}
+
+
+
+ }
+
+
+ }
+
+ } @else {
+
+
+
+ No available time slots at this time.
+
+
+
+ }
+
+
+
diff --git a/santashop-admin/src/app/shared/components/date-time-modal/date-time-modal.component.scss b/santashop-admin/src/app/shared/components/date-time-modal/date-time-modal.component.scss
new file mode 100644
index 00000000..6e403e66
--- /dev/null
+++ b/santashop-admin/src/app/shared/components/date-time-modal/date-time-modal.component.scss
@@ -0,0 +1,12 @@
+ion-card-title {
+ font-weight: bold;
+}
+
+ion-accordion-group {
+ margin: 0 1rem;
+}
+
+ion-title {
+ font-size: 1.2rem;
+ font-weight: bold;
+}
diff --git a/santashop-admin/src/app/shared/components/date-time-modal/date-time-modal.component.spec.ts b/santashop-admin/src/app/shared/components/date-time-modal/date-time-modal.component.spec.ts
new file mode 100644
index 00000000..985d7231
--- /dev/null
+++ b/santashop-admin/src/app/shared/components/date-time-modal/date-time-modal.component.spec.ts
@@ -0,0 +1,25 @@
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { IonicModule } from '@ionic/angular';
+
+import { DateTimeModalComponent } from './date-time-modal.component';
+import { testHelpers } from '../../../../test-helpers';
+
+describe('DateTimeModalComponent', () => {
+ let component: DateTimeModalComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [IonicModule.forRoot(), DateTimeModalComponent],
+ providers: [...testHelpers],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(DateTimeModalComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ }));
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/santashop-admin/src/app/shared/components/date-time-modal/date-time-modal.component.ts b/santashop-admin/src/app/shared/components/date-time-modal/date-time-modal.component.ts
new file mode 100644
index 00000000..624c6d94
--- /dev/null
+++ b/santashop-admin/src/app/shared/components/date-time-modal/date-time-modal.component.ts
@@ -0,0 +1,171 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ Input,
+ OnDestroy,
+ inject,
+} from '@angular/core';
+import {
+ AlertController,
+ ModalController,
+ IonHeader,
+ IonToolbar,
+ IonTitle,
+ IonButton,
+ IonContent,
+ IonList,
+ IonListHeader,
+ IonAccordionGroup,
+ IonAccordion,
+ IonItem,
+ IonLabel,
+ IonText,
+ IonCard,
+ IonCardHeader,
+ IonCardContent,
+ IonCardTitle,
+ IonNote,
+} from '@ionic/angular/standalone';
+import {
+ BehaviorSubject,
+ Observable,
+ Subject,
+ map,
+ shareReplay,
+ takeUntil,
+ distinctUntilChanged,
+} from 'rxjs';
+import { AsyncPipe, DatePipe } from '@angular/common';
+import type { DateTimeSlot } from '@santashop/models';
+import { TimeSlotPipe, CoreModule } from '@santashop/core';
+import { DateTimeModalService } from './date-time-modal.service';
+
+@Component({
+ selector: 'admin-date-time-modal',
+ templateUrl: './date-time-modal.component.html',
+ styleUrls: ['./date-time-modal.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ providers: [DateTimeModalService],
+ imports: [
+ AsyncPipe,
+ DatePipe,
+ TimeSlotPipe,
+ CoreModule,
+ IonHeader,
+ IonToolbar,
+ IonTitle,
+ IonButton,
+ IonContent,
+ IonList,
+ IonListHeader,
+ IonAccordionGroup,
+ IonAccordion,
+ IonItem,
+ IonLabel,
+ IonText,
+ IonCard,
+ IonCardHeader,
+ IonCardContent,
+ IonCardTitle,
+ IonNote,
+ ],
+})
+export class DateTimeModalComponent implements OnDestroy {
+ private readonly modalController = inject(ModalController);
+ private readonly alertController = inject(AlertController);
+ private readonly dateTimeService = inject(DateTimeModalService);
+
+ @Input() currentSlot?: DateTimeSlot;
+
+ private readonly destroy$ = new Subject();
+
+ private readonly selectedSlot = new BehaviorSubject<
+ DateTimeSlot | undefined
+ >(undefined);
+ public readonly selectedSlot$ = this.selectedSlot.asObservable();
+
+ public readonly availableSlots$ = this.dateTimeService.availableSlots$.pipe(
+ takeUntil(this.destroy$),
+ map((slots: DateTimeSlot[]) => slots.filter((slot) => slot.enabled)),
+ distinctUntilChanged(
+ (prev, curr) => JSON.stringify(prev) === JSON.stringify(curr),
+ ),
+ shareReplay(1),
+ );
+
+ public readonly availableDays$ = this.availableSlots$.pipe(
+ takeUntil(this.destroy$),
+ map((slots: DateTimeSlot[]) =>
+ slots.map((slot) => Date.parse(slot.dateTime.toDateString())),
+ ),
+ map((dates: number[]) => [...new Set(dates)]),
+ shareReplay(1),
+ );
+
+ public readonly availableSlotsByDay$ = (
+ date: number,
+ ): Observable =>
+ this.availableSlots$.pipe(
+ takeUntil(this.destroy$),
+ map((slots: DateTimeSlot[]) =>
+ slots.filter(
+ (slot) => Date.parse(slot.dateTime.toDateString()) === date,
+ ),
+ ),
+ shareReplay(1),
+ );
+
+ public ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ public async selectDateTime(slot?: DateTimeSlot): Promise {
+ const hasSlot = !!this.currentSlot;
+ let shouldChange = false;
+
+ if (hasSlot && slot) {
+ shouldChange = await this.confirmChangeDate();
+ }
+
+ if (!hasSlot || shouldChange) {
+ this.selectedSlot.next(slot);
+ await this.dismiss();
+ }
+ }
+
+ public spotsRemaining(slot: DateTimeSlot): string {
+ const spots = slot.maxSlots - (slot.slotsReserved ?? 0);
+
+ if (!slot.enabled || spots <= 0) return 'Unavailable';
+
+ return spots === 1 ? `${spots} spot` : `${spots} spots`;
+ }
+
+ public async dismiss(): Promise {
+ const slot = this.selectedSlot.getValue();
+ await this.modalController.dismiss(slot);
+ }
+
+ private async confirmChangeDate(): Promise {
+ const alert = await this.alertController.create({
+ header: 'Confirm Changes',
+ subHeader: 'Are you sure you want to change the date/time?',
+ message:
+ 'The slot this customer already has may no longer be available if you continue.',
+ buttons: [
+ {
+ text: 'Go Back',
+ role: 'cancel',
+ },
+ {
+ text: 'Continue',
+ },
+ ],
+ });
+
+ await alert.present();
+
+ return alert.onDidDismiss().then((e) => e.role !== 'cancel');
+ }
+}
diff --git a/santashop-admin/src/app/shared/components/date-time-modal/date-time-modal.service.ts b/santashop-admin/src/app/shared/components/date-time-modal/date-time-modal.service.ts
new file mode 100644
index 00000000..52729cfa
--- /dev/null
+++ b/santashop-admin/src/app/shared/components/date-time-modal/date-time-modal.service.ts
@@ -0,0 +1,63 @@
+import { Injectable, OnDestroy, inject } from '@angular/core';
+import {
+ FireRepoLite,
+ IFireRepoCollection,
+ PROGRAM_YEAR,
+ timestampToDate,
+} from '@santashop/core';
+import { Observable, Subject } from 'rxjs';
+import { map, shareReplay, takeUntil } from 'rxjs/operators';
+import { COLLECTION_SCHEMA, DateTimeSlot } from '@santashop/models';
+import { QueryConstraint, where } from '@angular/fire/firestore';
+
+@Injectable()
+export class DateTimeModalService implements OnDestroy {
+ private readonly programYear = inject(PROGRAM_YEAR);
+ private readonly fireRepo = inject(FireRepoLite);
+
+ private readonly destroy$ = new Subject();
+
+ public readonly availableSlots$ = this.availableSlotsQuery(
+ this.programYear,
+ ).pipe(
+ takeUntil(this.destroy$),
+ map((data) => {
+ for (const s of data) {
+ s.dateTime = timestampToDate(s.dateTime);
+ }
+ return data;
+ }),
+ map((data) =>
+ data
+ .slice()
+ .sort((a, b) => a.dateTime.valueOf() - b.dateTime.valueOf()),
+ ),
+ shareReplay(1),
+ );
+
+ public ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ private dateTimeSlotCollection(): IFireRepoCollection {
+ return this.fireRepo.collection(
+ COLLECTION_SCHEMA.dateTimeSlots,
+ );
+ }
+
+ /**
+ * Returns all time slots for the specified program year
+ * where the field 'enabled' is true.
+ */
+ private availableSlotsQuery(
+ programYear: number,
+ ): Observable {
+ const queryConstraints: QueryConstraint[] = [
+ where('programYear', '==', programYear),
+ where('enabled', '==', true),
+ ];
+
+ return this.dateTimeSlotCollection().readMany(queryConstraints, 'id');
+ }
+}
diff --git a/santashop-admin/src/main.ts b/santashop-admin/src/main.ts
index 4176db6a..ef276358 100644
--- a/santashop-admin/src/main.ts
+++ b/santashop-admin/src/main.ts
@@ -45,6 +45,7 @@ import {
getFirestore,
provideFirestore,
} from '@angular/fire/firestore';
+import { PROGRAM_YEAR } from '@santashop/core';
const firebaseProviders = [
provideFirebaseApp(() => initializeApp(firebaseConfig)),
@@ -106,5 +107,6 @@ bootstrapApplication(AppComponent, {
// App settings
ScreenTrackingService,
UserTrackingService,
+ { provide: PROGRAM_YEAR, useValue: 2025 },
],
}).catch((err) => console.log(err));
diff --git a/santashop-app/package.json b/santashop-app/package.json
index fa58b2c8..f133ea49 100644
--- a/santashop-app/package.json
+++ b/santashop-app/package.json
@@ -1,6 +1,6 @@
{
"name": "@santashop/app",
- "version": "2025.1.0",
+ "version": "2025.2.0",
"description": "Coming soon.",
"main": "index.js",
"scripts": {
diff --git a/santashop-functions/CHANGELOG.md b/santashop-functions/CHANGELOG.md
deleted file mode 100644
index 64670adb..00000000
--- a/santashop-functions/CHANGELOG.md
+++ /dev/null
@@ -1,44 +0,0 @@
-# Changelog - @santashop/functions
-
-All notable changes to the Firebase Cloud Functions will be documented in this file.
-
-## [2025.0.0] - 2025-11-09
-
-### Added
-
-- **Test Helpers**: New comprehensive test helper utilities
-- **Documentation**: Added detailed Functions Shell guide
-- **Email Template**: New 2025 registration confirmation email template
-- Function-specific README documentation
-
-### Changed
-
-- **Breaking**: Migrated to ESLint flat config format
-- **Breaking**: Updated to Node.js 22
-- **Dependencies**: Updated Firebase Admin SDK to v13.6.0
-- **Dependencies**: Updated Firebase Functions to v6.6.0
-- **Dependencies**: Updated AWS SDK to v3.925.0
-- **Build**: Enhanced webpack configuration for better bundling
-- **Functions**: Updated all function implementations with improved error handling
-- **Scheduled Functions**: Improved scheduled tasks for stats and maintenance
-
-### Removed
-
-- Legacy `.eslintrc.js` configuration
-- Legacy `.eslintignore` file
-- Old `package-lock.json` (functions has its own npm setup)
-
-### Fixed
-
-- Type errors across function implementations
-- Import path inconsistencies
-- Function deployment configurations
-
----
-
-## Previous Versions
-
-### [No Previous Version]
-
-- Functions were previously unversioned
-- See git history for changes prior to 2025.0.0
diff --git a/santashop-functions/src/fn/changeRegistrationDateTime.ts b/santashop-functions/src/fn/changeRegistrationDateTime.ts
index d147f50b..5ba0ff92 100644
--- a/santashop-functions/src/fn/changeRegistrationDateTime.ts
+++ b/santashop-functions/src/fn/changeRegistrationDateTime.ts
@@ -13,15 +13,25 @@ admin.initializeApp();
interface ChangeRegistrationData {
newDateTimeSlot: DateTimeSlot;
+ registrationUid?: string;
}
export default async function changeRegistrationDateTime(
data: ChangeRegistrationData,
context: CallableContext,
): Promise {
- const uid = context.auth?.uid;
+ // If registrationUid is provided (admin editing another user), use it; otherwise use authenticated user's uid
+ const isAdmin = context.auth?.token?.['admin'];
+ const uid = data.registrationUid ?? context.auth?.uid;
if (!uid) throw new HttpsError('unauthenticated', 'User not authenticated');
+ if (!isAdmin && data.registrationUid) {
+ throw new HttpsError(
+ 'permission-denied',
+ "Only admins can change other users' registrations",
+ );
+ }
+
if (!data.newDateTimeSlot) {
throw new HttpsError(
'invalid-argument',
@@ -76,7 +86,6 @@ export default async function changeRegistrationDateTime(
};
registrationDoc.includedInCounts = false;
- batch.set(registrationDocRef, registrationDoc);
batch.set(registrationDocRef, registrationDoc);
// Create email record for new confirmation email