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