@blackcode_sa/metaestetics-api 1.12.72 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/admin/index.d.mts +872 -1
  2. package/dist/admin/index.d.ts +872 -1
  3. package/dist/admin/index.js +3604 -356
  4. package/dist/admin/index.mjs +3594 -357
  5. package/dist/index.d.mts +1349 -1
  6. package/dist/index.d.ts +1349 -1
  7. package/dist/index.js +5325 -2141
  8. package/dist/index.mjs +4939 -1767
  9. package/package.json +1 -1
  10. package/src/admin/analytics/analytics.admin.service.ts +278 -0
  11. package/src/admin/analytics/index.ts +2 -0
  12. package/src/admin/index.ts +6 -0
  13. package/src/backoffice/services/analytics.service.proposal.md +4 -0
  14. package/src/services/analytics/ARCHITECTURE.md +199 -0
  15. package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -0
  16. package/src/services/analytics/GROUPED_ANALYTICS.md +501 -0
  17. package/src/services/analytics/QUICK_START.md +393 -0
  18. package/src/services/analytics/README.md +304 -0
  19. package/src/services/analytics/SUMMARY.md +141 -0
  20. package/src/services/analytics/TRENDS.md +380 -0
  21. package/src/services/analytics/USAGE_GUIDE.md +518 -0
  22. package/src/services/analytics/analytics-cloud.service.ts +222 -0
  23. package/src/services/analytics/analytics.service.ts +2142 -0
  24. package/src/services/analytics/index.ts +4 -0
  25. package/src/services/analytics/review-analytics.service.ts +941 -0
  26. package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -0
  27. package/src/services/analytics/utils/cost-calculation.utils.ts +182 -0
  28. package/src/services/analytics/utils/grouping.utils.ts +434 -0
  29. package/src/services/analytics/utils/stored-analytics.utils.ts +347 -0
  30. package/src/services/analytics/utils/time-calculation.utils.ts +186 -0
  31. package/src/services/analytics/utils/trend-calculation.utils.ts +200 -0
  32. package/src/services/index.ts +1 -0
  33. package/src/types/analytics/analytics.types.ts +597 -0
  34. package/src/types/analytics/grouped-analytics.types.ts +173 -0
  35. package/src/types/analytics/index.ts +4 -0
  36. package/src/types/analytics/stored-analytics.types.ts +137 -0
  37. package/src/types/index.ts +3 -0
@@ -481,6 +481,14 @@ var AppointmentStatus = /* @__PURE__ */ ((AppointmentStatus2) => {
481
481
  AppointmentStatus2["RESCHEDULED_BY_CLINIC"] = "rescheduled_by_clinic";
482
482
  return AppointmentStatus2;
483
483
  })(AppointmentStatus || {});
484
+ var PaymentStatus = /* @__PURE__ */ ((PaymentStatus2) => {
485
+ PaymentStatus2["UNPAID"] = "unpaid";
486
+ PaymentStatus2["PAID"] = "paid";
487
+ PaymentStatus2["PARTIALLY_PAID"] = "partially_paid";
488
+ PaymentStatus2["REFUNDED"] = "refunded";
489
+ PaymentStatus2["NOT_APPLICABLE"] = "not_applicable";
490
+ return PaymentStatus2;
491
+ })(PaymentStatus || {});
484
492
  var APPOINTMENTS_COLLECTION = "appointments";
485
493
 
486
494
  // src/types/patient/patient-requirements.ts
@@ -525,6 +533,17 @@ var DOCTOR_FORMS_SUBCOLLECTION = "doctor-forms";
525
533
  // src/types/reviews/index.ts
526
534
  var REVIEWS_COLLECTION = "reviews";
527
535
 
536
+ // src/types/analytics/stored-analytics.types.ts
537
+ var ANALYTICS_COLLECTION = "analytics";
538
+ var PRACTITIONER_ANALYTICS_SUBCOLLECTION = "practitioners";
539
+ var PROCEDURE_ANALYTICS_SUBCOLLECTION = "procedures";
540
+ var CLINIC_ANALYTICS_SUBCOLLECTION = "clinic";
541
+ var DASHBOARD_ANALYTICS_SUBCOLLECTION = "dashboard";
542
+ var TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION = "time_efficiency";
543
+ var CANCELLATION_ANALYTICS_SUBCOLLECTION = "cancellations";
544
+ var NO_SHOW_ANALYTICS_SUBCOLLECTION = "no_shows";
545
+ var REVENUE_ANALYTICS_SUBCOLLECTION = "revenue";
546
+
528
547
  // src/admin/notifications/notifications.admin.ts
529
548
  import * as admin2 from "firebase-admin";
530
549
  import { Expo } from "expo-server-sdk";
@@ -589,16 +608,16 @@ var Logger = class {
589
608
 
590
609
  // src/admin/notifications/notifications.admin.ts
591
610
  var NotificationsAdmin = class {
592
- constructor(firestore18) {
611
+ constructor(firestore19) {
593
612
  this.expo = new Expo();
594
- this.db = firestore18 || admin2.firestore();
613
+ this.db = firestore19 || admin2.firestore();
595
614
  }
596
615
  /**
597
616
  * Dohvata notifikaciju po ID-u
598
617
  */
599
618
  async getNotification(id) {
600
- const doc = await this.db.collection("notifications").doc(id).get();
601
- return doc.exists ? { id: doc.id, ...doc.data() } : null;
619
+ const doc3 = await this.db.collection("notifications").doc(id).get();
620
+ return doc3.exists ? { id: doc3.id, ...doc3.data() } : null;
602
621
  }
603
622
  /**
604
623
  * Kreira novu notifikaciju
@@ -785,10 +804,10 @@ var NotificationsAdmin = class {
785
804
  return;
786
805
  }
787
806
  const results = await Promise.allSettled(
788
- pendingNotifications.docs.map(async (doc) => {
807
+ pendingNotifications.docs.map(async (doc3) => {
789
808
  const notification = {
790
- id: doc.id,
791
- ...doc.data()
809
+ id: doc3.id,
810
+ ...doc3.data()
792
811
  };
793
812
  Logger.info(
794
813
  `[NotificationsAdmin] Processing notification ${notification.id} of type ${notification.notificationType}`
@@ -829,8 +848,8 @@ var NotificationsAdmin = class {
829
848
  break;
830
849
  }
831
850
  const batch = this.db.batch();
832
- oldNotifications.docs.forEach((doc) => {
833
- batch.delete(doc.ref);
851
+ oldNotifications.docs.forEach((doc3) => {
852
+ batch.delete(doc3.ref);
834
853
  });
835
854
  await batch.commit();
836
855
  totalDeleted += oldNotifications.size;
@@ -1100,8 +1119,8 @@ var NotificationsAdmin = class {
1100
1119
 
1101
1120
  // src/admin/requirements/patient-requirements.admin.service.ts
1102
1121
  var PatientRequirementsAdminService = class {
1103
- constructor(firestore18) {
1104
- this.db = firestore18 || admin3.firestore();
1122
+ constructor(firestore19) {
1123
+ this.db = firestore19 || admin3.firestore();
1105
1124
  this.notificationsAdmin = new NotificationsAdmin(this.db);
1106
1125
  }
1107
1126
  /**
@@ -1429,8 +1448,8 @@ var PatientRequirementsAdminService = class {
1429
1448
  // src/admin/calendar/calendar.admin.service.ts
1430
1449
  import * as admin4 from "firebase-admin";
1431
1450
  var CalendarAdminService = class {
1432
- constructor(firestore18) {
1433
- this.db = firestore18 || admin4.firestore();
1451
+ constructor(firestore19) {
1452
+ this.db = firestore19 || admin4.firestore();
1434
1453
  Logger.info("[CalendarAdminService] Initialized.");
1435
1454
  }
1436
1455
  /**
@@ -1716,9 +1735,9 @@ var BaseMailingService = class {
1716
1735
  * @param firestore Firestore instance provided by the caller
1717
1736
  * @param mailgunClient Mailgun client instance (mailgun.js v10+) provided by the caller
1718
1737
  */
1719
- constructor(firestore18, mailgunClient) {
1738
+ constructor(firestore19, mailgunClient) {
1720
1739
  var _a;
1721
- this.db = firestore18;
1740
+ this.db = firestore19;
1722
1741
  this.mailgunClient = mailgunClient;
1723
1742
  if (!this.db) {
1724
1743
  Logger.error("[BaseMailingService] No Firestore instance provided");
@@ -2260,8 +2279,8 @@ var clinicAppointmentRequestedTemplate = `
2260
2279
  </html>
2261
2280
  `;
2262
2281
  var AppointmentMailingService = class extends BaseMailingService {
2263
- constructor(firestore18, mailgunClient) {
2264
- super(firestore18, mailgunClient);
2282
+ constructor(firestore19, mailgunClient) {
2283
+ super(firestore19, mailgunClient);
2265
2284
  this.DEFAULT_MAILGUN_DOMAIN = "mg.metaesthetics.net";
2266
2285
  Logger.info("[AppointmentMailingService] Initialized.");
2267
2286
  }
@@ -2490,8 +2509,8 @@ var AppointmentAggregationService = class {
2490
2509
  * @param mailgunClient - An initialized Mailgun client instance.
2491
2510
  * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
2492
2511
  */
2493
- constructor(mailgunClient, firestore18) {
2494
- this.db = firestore18 || admin6.firestore();
2512
+ constructor(mailgunClient, firestore19) {
2513
+ this.db = firestore19 || admin6.firestore();
2495
2514
  this.appointmentMailingService = new AppointmentMailingService(
2496
2515
  this.db,
2497
2516
  mailgunClient
@@ -3518,10 +3537,10 @@ var AppointmentAggregationService = class {
3518
3537
  }
3519
3538
  const batch = this.db.batch();
3520
3539
  let instancesUpdatedCount = 0;
3521
- instancesSnapshot.docs.forEach((doc) => {
3522
- const instance = doc.data();
3540
+ instancesSnapshot.docs.forEach((doc3) => {
3541
+ const instance = doc3.data();
3523
3542
  if (instance.overallStatus !== newOverallStatus && instance.overallStatus !== "failedToProcess" /* FAILED_TO_PROCESS */) {
3524
- batch.update(doc.ref, {
3543
+ batch.update(doc3.ref, {
3525
3544
  overallStatus: newOverallStatus,
3526
3545
  updatedAt: admin6.firestore.FieldValue.serverTimestamp()
3527
3546
  // Cast for now
@@ -3530,7 +3549,7 @@ var AppointmentAggregationService = class {
3530
3549
  });
3531
3550
  instancesUpdatedCount++;
3532
3551
  Logger.debug(
3533
- `[AggService] Added update for PatientRequirementInstance ${doc.id} to batch. New status: ${newOverallStatus}`
3552
+ `[AggService] Added update for PatientRequirementInstance ${doc3.id} to batch. New status: ${newOverallStatus}`
3534
3553
  );
3535
3554
  }
3536
3555
  });
@@ -3705,8 +3724,8 @@ var AppointmentAggregationService = class {
3705
3724
  // --- Data Fetching Helpers (Consider moving to a data access layer or using existing services if available) ---
3706
3725
  async fetchPatientProfile(patientId) {
3707
3726
  try {
3708
- const doc = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).get();
3709
- return doc.exists ? doc.data() : null;
3727
+ const doc3 = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).get();
3728
+ return doc3.exists ? doc3.data() : null;
3710
3729
  } catch (error) {
3711
3730
  Logger.error(`[AggService] Error fetching patient profile ${patientId}:`, error);
3712
3731
  return null;
@@ -3719,12 +3738,12 @@ var AppointmentAggregationService = class {
3719
3738
  */
3720
3739
  async fetchPatientSensitiveInfo(patientId) {
3721
3740
  try {
3722
- const doc = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).collection(PATIENT_SENSITIVE_INFO_COLLECTION).doc(patientId).get();
3723
- if (!doc.exists) {
3741
+ const doc3 = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).collection(PATIENT_SENSITIVE_INFO_COLLECTION).doc(patientId).get();
3742
+ if (!doc3.exists) {
3724
3743
  Logger.warn(`[AggService] No sensitive info found for patient ${patientId}`);
3725
3744
  return null;
3726
3745
  }
3727
- return doc.data();
3746
+ return doc3.data();
3728
3747
  } catch (error) {
3729
3748
  Logger.error(`[AggService] Error fetching patient sensitive info ${patientId}:`, error);
3730
3749
  return null;
@@ -3741,12 +3760,12 @@ var AppointmentAggregationService = class {
3741
3760
  return null;
3742
3761
  }
3743
3762
  try {
3744
- const doc = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
3745
- if (!doc.exists) {
3763
+ const doc3 = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
3764
+ if (!doc3.exists) {
3746
3765
  Logger.warn(`[AggService] No practitioner profile found for ID ${practitionerId}`);
3747
3766
  return null;
3748
3767
  }
3749
- return doc.data();
3768
+ return doc3.data();
3750
3769
  } catch (error) {
3751
3770
  Logger.error(`[AggService] Error fetching practitioner profile ${practitionerId}:`, error);
3752
3771
  return null;
@@ -3763,12 +3782,12 @@ var AppointmentAggregationService = class {
3763
3782
  return null;
3764
3783
  }
3765
3784
  try {
3766
- const doc = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
3767
- if (!doc.exists) {
3785
+ const doc3 = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
3786
+ if (!doc3.exists) {
3768
3787
  Logger.warn(`[AggService] No clinic info found for ID ${clinicId}`);
3769
3788
  return null;
3770
3789
  }
3771
- return doc.data();
3790
+ return doc3.data();
3772
3791
  } catch (error) {
3773
3792
  Logger.error(`[AggService] Error fetching clinic info ${clinicId}:`, error);
3774
3793
  return null;
@@ -4012,8 +4031,8 @@ var ClinicAggregationService = class {
4012
4031
  * Constructor for ClinicAggregationService.
4013
4032
  * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
4014
4033
  */
4015
- constructor(firestore18) {
4016
- this.db = firestore18 || admin7.firestore();
4034
+ constructor(firestore19) {
4035
+ this.db = firestore19 || admin7.firestore();
4017
4036
  }
4018
4037
  /**
4019
4038
  * Adds clinic information to a clinic group when a new clinic is created
@@ -4235,11 +4254,11 @@ var ClinicAggregationService = class {
4235
4254
  return;
4236
4255
  }
4237
4256
  const batch = this.db.batch();
4238
- snapshot.docs.forEach((doc) => {
4257
+ snapshot.docs.forEach((doc3) => {
4239
4258
  console.log(
4240
- `[ClinicAggregationService] Updating location for calendar event ${doc.ref.path}`
4259
+ `[ClinicAggregationService] Updating location for calendar event ${doc3.ref.path}`
4241
4260
  );
4242
- batch.update(doc.ref, {
4261
+ batch.update(doc3.ref, {
4243
4262
  eventLocation: newLocation,
4244
4263
  updatedAt: admin7.firestore.FieldValue.serverTimestamp()
4245
4264
  });
@@ -4282,11 +4301,11 @@ var ClinicAggregationService = class {
4282
4301
  return;
4283
4302
  }
4284
4303
  const batch = this.db.batch();
4285
- snapshot.docs.forEach((doc) => {
4304
+ snapshot.docs.forEach((doc3) => {
4286
4305
  console.log(
4287
- `[ClinicAggregationService] Updating clinic info for calendar event ${doc.ref.path}`
4306
+ `[ClinicAggregationService] Updating clinic info for calendar event ${doc3.ref.path}`
4288
4307
  );
4289
- batch.update(doc.ref, {
4308
+ batch.update(doc3.ref, {
4290
4309
  clinicInfo,
4291
4310
  updatedAt: admin7.firestore.FieldValue.serverTimestamp()
4292
4311
  });
@@ -4497,11 +4516,11 @@ var ClinicAggregationService = class {
4497
4516
  return;
4498
4517
  }
4499
4518
  const batch = this.db.batch();
4500
- snapshot.docs.forEach((doc) => {
4519
+ snapshot.docs.forEach((doc3) => {
4501
4520
  console.log(
4502
- `[ClinicAggregationService] Canceling calendar event ${doc.ref.path}`
4521
+ `[ClinicAggregationService] Canceling calendar event ${doc3.ref.path}`
4503
4522
  );
4504
- batch.update(doc.ref, {
4523
+ batch.update(doc3.ref, {
4505
4524
  status: "CANCELED",
4506
4525
  cancelReason: "Clinic deleted",
4507
4526
  updatedAt: admin7.firestore.FieldValue.serverTimestamp()
@@ -4528,8 +4547,8 @@ var FilledFormsAggregationService = class {
4528
4547
  * Constructor for FilledFormsAggregationService.
4529
4548
  * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
4530
4549
  */
4531
- constructor(firestore18) {
4532
- this.db = firestore18 || admin8.firestore();
4550
+ constructor(firestore19) {
4551
+ this.db = firestore19 || admin8.firestore();
4533
4552
  Logger.info("[FilledFormsAggregationService] Initialized");
4534
4553
  }
4535
4554
  /**
@@ -4736,8 +4755,8 @@ var FilledFormsAggregationService = class {
4736
4755
  import * as admin9 from "firebase-admin";
4737
4756
  var CALENDAR_SUBCOLLECTION_ID2 = "calendar";
4738
4757
  var PatientAggregationService = class {
4739
- constructor(firestore18) {
4740
- this.db = firestore18 || admin9.firestore();
4758
+ constructor(firestore19) {
4759
+ this.db = firestore19 || admin9.firestore();
4741
4760
  }
4742
4761
  // --- Methods for Patient Creation --- >
4743
4762
  // No specific aggregations defined for patient creation in the plan.
@@ -4769,11 +4788,11 @@ var PatientAggregationService = class {
4769
4788
  return;
4770
4789
  }
4771
4790
  const batch = this.db.batch();
4772
- snapshot.docs.forEach((doc) => {
4791
+ snapshot.docs.forEach((doc3) => {
4773
4792
  console.log(
4774
- `[PatientAggregationService] Updating patient info for calendar event ${doc.ref.path}`
4793
+ `[PatientAggregationService] Updating patient info for calendar event ${doc3.ref.path}`
4775
4794
  );
4776
- batch.update(doc.ref, {
4795
+ batch.update(doc3.ref, {
4777
4796
  patientInfo,
4778
4797
  updatedAt: admin9.firestore.FieldValue.serverTimestamp()
4779
4798
  });
@@ -4817,11 +4836,11 @@ var PatientAggregationService = class {
4817
4836
  return;
4818
4837
  }
4819
4838
  const batch = this.db.batch();
4820
- snapshot.docs.forEach((doc) => {
4839
+ snapshot.docs.forEach((doc3) => {
4821
4840
  console.log(
4822
- `[PatientAggregationService] Canceling calendar event ${doc.ref.path}`
4841
+ `[PatientAggregationService] Canceling calendar event ${doc3.ref.path}`
4823
4842
  );
4824
- batch.update(doc.ref, {
4843
+ batch.update(doc3.ref, {
4825
4844
  status: "CANCELED",
4826
4845
  cancelReason: "Patient deleted",
4827
4846
  updatedAt: admin9.firestore.FieldValue.serverTimestamp()
@@ -4845,8 +4864,8 @@ var PatientAggregationService = class {
4845
4864
  import * as admin10 from "firebase-admin";
4846
4865
  var CALENDAR_SUBCOLLECTION_ID3 = "calendar";
4847
4866
  var PractitionerAggregationService = class {
4848
- constructor(firestore18) {
4849
- this.db = firestore18 || admin10.firestore();
4867
+ constructor(firestore19) {
4868
+ this.db = firestore19 || admin10.firestore();
4850
4869
  }
4851
4870
  /**
4852
4871
  * Adds practitioner information to a clinic when a new practitioner is created
@@ -4992,11 +5011,11 @@ var PractitionerAggregationService = class {
4992
5011
  return;
4993
5012
  }
4994
5013
  const batch = this.db.batch();
4995
- snapshot.docs.forEach((doc) => {
5014
+ snapshot.docs.forEach((doc3) => {
4996
5015
  console.log(
4997
- `[PractitionerAggregationService] Updating practitioner info for calendar event ${doc.ref.path}`
5016
+ `[PractitionerAggregationService] Updating practitioner info for calendar event ${doc3.ref.path}`
4998
5017
  );
4999
- batch.update(doc.ref, {
5018
+ batch.update(doc3.ref, {
5000
5019
  practitionerInfo,
5001
5020
  updatedAt: admin10.firestore.FieldValue.serverTimestamp()
5002
5021
  });
@@ -5080,11 +5099,11 @@ var PractitionerAggregationService = class {
5080
5099
  return;
5081
5100
  }
5082
5101
  const batch = this.db.batch();
5083
- snapshot.docs.forEach((doc) => {
5102
+ snapshot.docs.forEach((doc3) => {
5084
5103
  console.log(
5085
- `[PractitionerAggregationService] Canceling calendar event ${doc.ref.path}`
5104
+ `[PractitionerAggregationService] Canceling calendar event ${doc3.ref.path}`
5086
5105
  );
5087
- batch.update(doc.ref, {
5106
+ batch.update(doc3.ref, {
5088
5107
  status: "CANCELED",
5089
5108
  cancelReason: "Practitioner deleted",
5090
5109
  updatedAt: admin10.firestore.FieldValue.serverTimestamp()
@@ -5185,8 +5204,8 @@ var PractitionerInviteAggregationService = class {
5185
5204
  * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
5186
5205
  * @param mailingService Optional mailing service for sending emails
5187
5206
  */
5188
- constructor(firestore18, mailingService) {
5189
- this.db = firestore18 || admin11.firestore();
5207
+ constructor(firestore19, mailingService) {
5208
+ this.db = firestore19 || admin11.firestore();
5190
5209
  this.mailingService = mailingService;
5191
5210
  Logger.info("[PractitionerInviteAggregationService] Initialized.");
5192
5211
  }
@@ -5636,8 +5655,8 @@ var PractitionerInviteAggregationService = class {
5636
5655
  */
5637
5656
  async fetchClinicAdminById(adminId) {
5638
5657
  try {
5639
- const doc = await this.db.collection(CLINIC_ADMINS_COLLECTION).doc(adminId).get();
5640
- return doc.exists ? doc.data() : null;
5658
+ const doc3 = await this.db.collection(CLINIC_ADMINS_COLLECTION).doc(adminId).get();
5659
+ return doc3.exists ? doc3.data() : null;
5641
5660
  } catch (error) {
5642
5661
  Logger.error(
5643
5662
  `[PractitionerInviteAggService] Error fetching clinic admin ${adminId}:`,
@@ -5653,8 +5672,8 @@ var PractitionerInviteAggregationService = class {
5653
5672
  */
5654
5673
  async fetchPractitionerById(practitionerId) {
5655
5674
  try {
5656
- const doc = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
5657
- return doc.exists ? doc.data() : null;
5675
+ const doc3 = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
5676
+ return doc3.exists ? doc3.data() : null;
5658
5677
  } catch (error) {
5659
5678
  Logger.error(
5660
5679
  `[PractitionerInviteAggService] Error fetching practitioner ${practitionerId}:`,
@@ -5670,8 +5689,8 @@ var PractitionerInviteAggregationService = class {
5670
5689
  */
5671
5690
  async fetchClinicById(clinicId) {
5672
5691
  try {
5673
- const doc = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
5674
- return doc.exists ? doc.data() : null;
5692
+ const doc3 = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
5693
+ return doc3.exists ? doc3.data() : null;
5675
5694
  } catch (error) {
5676
5695
  Logger.error(
5677
5696
  `[PractitionerInviteAggService] Error fetching clinic ${clinicId}:`,
@@ -5692,8 +5711,8 @@ var PractitionerInviteAggregationService = class {
5692
5711
  var _a, _b, _c, _d, _e, _f;
5693
5712
  if (!this.mailingService) return;
5694
5713
  try {
5695
- const admin18 = await this.fetchClinicAdminById(invite.invitedBy);
5696
- if (!admin18) {
5714
+ const admin19 = await this.fetchClinicAdminById(invite.invitedBy);
5715
+ if (!admin19) {
5697
5716
  Logger.warn(
5698
5717
  `[PractitionerInviteAggService] Admin ${invite.invitedBy} not found, using clinic contact email as fallback`
5699
5718
  );
@@ -5731,7 +5750,7 @@ var PractitionerInviteAggregationService = class {
5731
5750
  );
5732
5751
  return;
5733
5752
  }
5734
- const adminName = `${admin18.contactInfo.firstName} ${admin18.contactInfo.lastName}`;
5753
+ const adminName = `${admin19.contactInfo.firstName} ${admin19.contactInfo.lastName}`;
5735
5754
  const notificationData = {
5736
5755
  invite,
5737
5756
  practitioner: {
@@ -5747,7 +5766,7 @@ var PractitionerInviteAggregationService = class {
5747
5766
  clinic: {
5748
5767
  name: clinic.name,
5749
5768
  adminName,
5750
- adminEmail: admin18.contactInfo.email
5769
+ adminEmail: admin19.contactInfo.email
5751
5770
  // Use the specific admin's email
5752
5771
  },
5753
5772
  context: {
@@ -5783,8 +5802,8 @@ var PractitionerInviteAggregationService = class {
5783
5802
  var _a, _b, _c, _d, _e, _f;
5784
5803
  if (!this.mailingService) return;
5785
5804
  try {
5786
- const admin18 = await this.fetchClinicAdminById(invite.invitedBy);
5787
- if (!admin18) {
5805
+ const admin19 = await this.fetchClinicAdminById(invite.invitedBy);
5806
+ if (!admin19) {
5788
5807
  Logger.warn(
5789
5808
  `[PractitionerInviteAggService] Admin ${invite.invitedBy} not found, using clinic contact email as fallback`
5790
5809
  );
@@ -5822,7 +5841,7 @@ var PractitionerInviteAggregationService = class {
5822
5841
  );
5823
5842
  return;
5824
5843
  }
5825
- const adminName = `${admin18.contactInfo.firstName} ${admin18.contactInfo.lastName}`;
5844
+ const adminName = `${admin19.contactInfo.firstName} ${admin19.contactInfo.lastName}`;
5826
5845
  const notificationData = {
5827
5846
  invite,
5828
5847
  practitioner: {
@@ -5836,7 +5855,7 @@ var PractitionerInviteAggregationService = class {
5836
5855
  clinic: {
5837
5856
  name: clinic.name,
5838
5857
  adminName,
5839
- adminEmail: admin18.contactInfo.email
5858
+ adminEmail: admin19.contactInfo.email
5840
5859
  // Use the specific admin's email
5841
5860
  },
5842
5861
  context: {
@@ -5868,8 +5887,8 @@ var PractitionerInviteAggregationService = class {
5868
5887
  import * as admin12 from "firebase-admin";
5869
5888
  var CALENDAR_SUBCOLLECTION_ID4 = "calendar";
5870
5889
  var ProcedureAggregationService = class {
5871
- constructor(firestore18) {
5872
- this.db = firestore18 || admin12.firestore();
5890
+ constructor(firestore19) {
5891
+ this.db = firestore19 || admin12.firestore();
5873
5892
  }
5874
5893
  /**
5875
5894
  * Adds procedure information to a practitioner when a new procedure is created
@@ -6106,11 +6125,11 @@ var ProcedureAggregationService = class {
6106
6125
  return;
6107
6126
  }
6108
6127
  const batch = this.db.batch();
6109
- snapshot.docs.forEach((doc) => {
6128
+ snapshot.docs.forEach((doc3) => {
6110
6129
  console.log(
6111
- `[ProcedureAggregationService] Updating procedure info for calendar event ${doc.ref.path}`
6130
+ `[ProcedureAggregationService] Updating procedure info for calendar event ${doc3.ref.path}`
6112
6131
  );
6113
- batch.update(doc.ref, {
6132
+ batch.update(doc3.ref, {
6114
6133
  procedureInfo,
6115
6134
  updatedAt: admin12.firestore.FieldValue.serverTimestamp()
6116
6135
  });
@@ -6153,11 +6172,11 @@ var ProcedureAggregationService = class {
6153
6172
  return;
6154
6173
  }
6155
6174
  const batch = this.db.batch();
6156
- snapshot.docs.forEach((doc) => {
6175
+ snapshot.docs.forEach((doc3) => {
6157
6176
  console.log(
6158
- `[ProcedureAggregationService] Canceling calendar event ${doc.ref.path}`
6177
+ `[ProcedureAggregationService] Canceling calendar event ${doc3.ref.path}`
6159
6178
  );
6160
- batch.update(doc.ref, {
6179
+ batch.update(doc3.ref, {
6161
6180
  status: "CANCELED",
6162
6181
  cancelReason: "Procedure deleted or inactivated",
6163
6182
  updatedAt: admin12.firestore.FieldValue.serverTimestamp()
@@ -6387,8 +6406,8 @@ var ReviewsAggregationService = class {
6387
6406
  * Constructor for ReviewsAggregationService.
6388
6407
  * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
6389
6408
  */
6390
- constructor(firestore18) {
6391
- this.db = firestore18 || admin13.firestore();
6409
+ constructor(firestore19) {
6410
+ this.db = firestore19 || admin13.firestore();
6392
6411
  }
6393
6412
  /**
6394
6413
  * Process a newly created review and update all related entities
@@ -6538,7 +6557,7 @@ var ReviewsAggregationService = class {
6538
6557
  );
6539
6558
  return updatedReviewInfo2;
6540
6559
  }
6541
- const reviews = reviewsQuery.docs.map((doc) => doc.data());
6560
+ const reviews = reviewsQuery.docs.map((doc3) => doc3.data());
6542
6561
  const clinicReviews = reviews.map((review) => review.clinicReview).filter((review) => review !== void 0);
6543
6562
  let totalRating = 0;
6544
6563
  let totalCleanliness = 0;
@@ -6628,7 +6647,7 @@ var ReviewsAggregationService = class {
6628
6647
  );
6629
6648
  return updatedReviewInfo2;
6630
6649
  }
6631
- const reviews = reviewsQuery.docs.map((doc) => doc.data());
6650
+ const reviews = reviewsQuery.docs.map((doc3) => doc3.data());
6632
6651
  const practitionerReviews = reviews.map((review) => review.practitionerReview).filter((review) => review !== void 0);
6633
6652
  let totalRating = 0;
6634
6653
  let totalKnowledgeAndExpertise = 0;
@@ -6701,7 +6720,7 @@ var ReviewsAggregationService = class {
6701
6720
  recommendationPercentage: 0
6702
6721
  };
6703
6722
  const allReviewsQuery = await this.db.collection(REVIEWS_COLLECTION).get();
6704
- const reviews = allReviewsQuery.docs.map((doc) => doc.data());
6723
+ const reviews = allReviewsQuery.docs.map((doc3) => doc3.data());
6705
6724
  const procedureReviews = [];
6706
6725
  reviews.forEach((review) => {
6707
6726
  if (review.procedureReview && review.procedureReview.procedureId === procedureId) {
@@ -6867,226 +6886,3433 @@ var ReviewsAggregationService = class {
6867
6886
  }
6868
6887
  };
6869
6888
 
6870
- // src/admin/booking/booking.calculator.ts
6871
- import { Timestamp } from "firebase/firestore";
6872
- import { DateTime as DateTime2 } from "luxon";
6873
- var BookingAvailabilityCalculator = class {
6874
- /**
6875
- * Calculate available booking slots based on the provided data
6876
- *
6877
- * @param request - The request containing all necessary data for calculation
6878
- * @returns Response with available booking slots
6879
- */
6880
- static calculateSlots(request) {
6881
- const {
6882
- clinic,
6883
- practitioner,
6884
- procedure,
6885
- timeframe,
6886
- clinicCalendarEvents,
6887
- practitionerCalendarEvents,
6888
- tz
6889
- } = request;
6890
- const schedulingIntervalMinutes = clinic.schedulingInterval || this.DEFAULT_INTERVAL_MINUTES;
6891
- const procedureDurationMinutes = procedure.duration;
6892
- console.log(
6893
- `Calculating slots with interval: ${schedulingIntervalMinutes}min and procedure duration: ${procedureDurationMinutes}min`
6894
- );
6895
- let availableIntervals = [
6896
- { start: timeframe.start, end: timeframe.end }
6897
- ];
6898
- availableIntervals = this.applyClinicWorkingHours(
6899
- availableIntervals,
6900
- clinic.workingHours,
6901
- timeframe,
6902
- tz
6903
- );
6904
- availableIntervals = this.subtractBlockingEvents(
6905
- availableIntervals,
6906
- clinicCalendarEvents
6907
- );
6908
- availableIntervals = this.applyPractitionerWorkingHours(
6909
- availableIntervals,
6910
- practitioner,
6911
- clinic.id,
6912
- timeframe,
6913
- tz
6914
- );
6915
- availableIntervals = this.subtractPractitionerBusyTimes(
6916
- availableIntervals,
6917
- practitionerCalendarEvents
6918
- );
6919
- console.log(
6920
- `After all filters, have ${availableIntervals.length} available intervals`
6921
- );
6922
- const availableSlots = this.generateAvailableSlots(
6923
- availableIntervals,
6924
- schedulingIntervalMinutes,
6925
- procedureDurationMinutes,
6926
- tz
6927
- );
6928
- return { availableSlots };
6889
+ // src/admin/analytics/analytics.admin.service.ts
6890
+ import * as admin14 from "firebase-admin";
6891
+
6892
+ // src/services/analytics/analytics.service.ts
6893
+ import { where as where2, Timestamp as Timestamp3 } from "firebase/firestore";
6894
+
6895
+ // src/services/base.service.ts
6896
+ import { getStorage } from "firebase/storage";
6897
+ var BaseService = class {
6898
+ constructor(db, auth, app, storage) {
6899
+ this.db = db;
6900
+ this.auth = auth;
6901
+ this.app = app;
6902
+ if (app) {
6903
+ this.storage = storage || getStorage(app);
6904
+ }
6905
+ }
6906
+ /**
6907
+ * Generiše jedinstveni ID za dokumente
6908
+ * Format: xxxxxxxxxxxx-timestamp
6909
+ * Gde je x random karakter (broj ili slovo)
6910
+ */
6911
+ generateId() {
6912
+ const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
6913
+ const timestamp = Date.now().toString(36);
6914
+ const randomPart = Array.from(
6915
+ { length: 12 },
6916
+ () => chars.charAt(Math.floor(Math.random() * chars.length))
6917
+ ).join("");
6918
+ return `${randomPart}-${timestamp}`;
6929
6919
  }
6930
- /**
6931
- * Apply clinic working hours to available intervals
6932
- *
6933
- * @param intervals - Current available intervals
6934
- * @param workingHours - Clinic working hours
6935
- * @param timeframe - Overall timeframe being considered
6936
- * @param tz - IANA timezone of the clinic
6937
- * @returns Intervals filtered by clinic working hours
6938
- */
6939
- static applyClinicWorkingHours(intervals, workingHours, timeframe, tz) {
6940
- if (!intervals.length) return [];
6941
- console.log(
6942
- `Applying clinic working hours to ${intervals.length} intervals`
6943
- );
6944
- const workingIntervals = this.createWorkingHoursIntervals(
6945
- workingHours,
6946
- timeframe.start.toDate(),
6947
- timeframe.end.toDate(),
6948
- tz
6949
- );
6950
- return this.intersectIntervals(intervals, workingIntervals);
6920
+ };
6921
+
6922
+ // src/services/analytics/utils/cost-calculation.utils.ts
6923
+ function calculateAppointmentCost(appointment) {
6924
+ const metadata = appointment.metadata;
6925
+ const currency = appointment.currency || "CHF";
6926
+ if (metadata == null ? void 0 : metadata.finalbilling) {
6927
+ const finalbilling = metadata.finalbilling;
6928
+ return {
6929
+ cost: finalbilling.finalPrice,
6930
+ currency: finalbilling.currency || currency,
6931
+ source: "finalbilling",
6932
+ subtotal: finalbilling.subtotalAll,
6933
+ tax: finalbilling.taxPrice
6934
+ };
6951
6935
  }
6952
- /**
6953
- * Create time intervals for working hours across multiple days
6954
- *
6955
- * @param workingHours - Working hours definition
6956
- * @param startDate - Start date of the overall timeframe
6957
- * @param endDate - End date of the overall timeframe
6958
- * @param tz - IANA timezone of the clinic
6959
- * @returns Array of time intervals representing working hours
6960
- */
6961
- static createWorkingHoursIntervals(workingHours, startDate, endDate, tz) {
6962
- const workingIntervals = [];
6963
- let start = DateTime2.fromMillis(startDate.getTime(), { zone: tz });
6964
- const end = DateTime2.fromMillis(endDate.getTime(), { zone: tz });
6965
- while (start <= end) {
6966
- const dayOfWeek = start.weekday;
6967
- const dayName = [
6968
- "monday",
6969
- "tuesday",
6970
- "wednesday",
6971
- "thursday",
6972
- "friday",
6973
- "saturday",
6974
- "sunday"
6975
- ][dayOfWeek - 1];
6976
- if (dayName && workingHours[dayName]) {
6977
- const daySchedule = workingHours[dayName];
6978
- if (daySchedule) {
6979
- const [openHours, openMinutes] = daySchedule.open.split(":").map(Number);
6980
- const [closeHours, closeMinutes] = daySchedule.close.split(":").map(Number);
6981
- let workStart = start.set({
6982
- hour: openHours,
6983
- minute: openMinutes,
6984
- second: 0,
6985
- millisecond: 0
6986
- });
6987
- let workEnd = start.set({
6988
- hour: closeHours,
6989
- minute: closeMinutes,
6990
- second: 0,
6991
- millisecond: 0
6992
- });
6993
- if (workEnd.toMillis() > startDate.getTime() && workStart.toMillis() < endDate.getTime()) {
6994
- const intervalStart = workStart < DateTime2.fromMillis(startDate.getTime(), { zone: tz }) ? DateTime2.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
6995
- const intervalEnd = workEnd > DateTime2.fromMillis(endDate.getTime(), { zone: tz }) ? DateTime2.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
6996
- workingIntervals.push({
6997
- start: Timestamp.fromMillis(intervalStart.toMillis()),
6998
- end: Timestamp.fromMillis(intervalEnd.toMillis())
6999
- });
7000
- if (daySchedule.breaks && daySchedule.breaks.length > 0) {
7001
- for (const breakTime of daySchedule.breaks) {
7002
- const [breakStartHours, breakStartMinutes] = breakTime.start.split(":").map(Number);
7003
- const [breakEndHours, breakEndMinutes] = breakTime.end.split(":").map(Number);
7004
- const breakStart = start.set({
7005
- hour: breakStartHours,
7006
- minute: breakStartMinutes
7007
- });
7008
- const breakEnd = start.set({
7009
- hour: breakEndHours,
7010
- minute: breakEndMinutes
7011
- });
7012
- workingIntervals.splice(
7013
- -1,
7014
- 1,
7015
- ...this.subtractInterval(
7016
- workingIntervals[workingIntervals.length - 1],
7017
- {
7018
- start: Timestamp.fromMillis(breakStart.toMillis()),
7019
- end: Timestamp.fromMillis(breakEnd.toMillis())
7020
- }
7021
- )
7022
- );
7023
- }
7024
- }
6936
+ if (metadata == null ? void 0 : metadata.zonesData) {
6937
+ const zonesData = metadata.zonesData;
6938
+ let subtotal = 0;
6939
+ let foundCurrency = currency;
6940
+ Object.values(zonesData).forEach((items) => {
6941
+ items.forEach((item) => {
6942
+ if (item.type === "item" && item.subtotal) {
6943
+ subtotal += item.subtotal;
6944
+ if (item.currency && !foundCurrency) {
6945
+ foundCurrency = item.currency;
7025
6946
  }
7026
6947
  }
7027
- }
7028
- start = start.plus({ days: 1 });
6948
+ });
6949
+ });
6950
+ if (subtotal > 0) {
6951
+ return {
6952
+ cost: subtotal,
6953
+ // Note: This doesn't include tax, but zonesData might not have tax info
6954
+ currency: foundCurrency,
6955
+ source: "zonesData",
6956
+ subtotal
6957
+ };
7029
6958
  }
7030
- return workingIntervals;
7031
6959
  }
7032
- /**
7033
- * Subtract blocking events from available intervals
7034
- *
7035
- * @param intervals - Current available intervals
7036
- * @param events - Calendar events to subtract
7037
- * @returns Available intervals after removing blocking events
7038
- */
7039
- static subtractBlockingEvents(intervals, events) {
7040
- if (!intervals.length) return [];
7041
- console.log(`Subtracting ${events.length} blocking events`);
7042
- const blockingEvents = events.filter(
7043
- (event) => event.eventType === "blocking" /* BLOCKING */ || event.eventType === "break" /* BREAK */ || event.eventType === "free_day" /* FREE_DAY */
7044
- );
7045
- let result = [...intervals];
7046
- for (const event of blockingEvents) {
7047
- const { start, end } = event.eventTime;
7048
- const blockingInterval = { start, end };
7049
- const newResult = [];
7050
- for (const interval of result) {
7051
- const remainingIntervals = this.subtractInterval(
7052
- interval,
7053
- blockingInterval
7054
- );
7055
- newResult.push(...remainingIntervals);
6960
+ return {
6961
+ cost: appointment.cost || 0,
6962
+ currency,
6963
+ source: "baseCost"
6964
+ };
6965
+ }
6966
+ function calculateTotalRevenue(appointments) {
6967
+ if (appointments.length === 0) {
6968
+ return { totalRevenue: 0, currency: "CHF" };
6969
+ }
6970
+ let totalRevenue = 0;
6971
+ const currencies = /* @__PURE__ */ new Set();
6972
+ appointments.forEach((appointment) => {
6973
+ const costData = calculateAppointmentCost(appointment);
6974
+ totalRevenue += costData.cost;
6975
+ currencies.add(costData.currency);
6976
+ });
6977
+ const currency = currencies.size > 0 ? Array.from(currencies)[0] : "CHF";
6978
+ return { totalRevenue, currency };
6979
+ }
6980
+ function extractProductUsage(appointment) {
6981
+ const products = [];
6982
+ const metadata = appointment.metadata;
6983
+ if (!(metadata == null ? void 0 : metadata.zonesData)) {
6984
+ return products;
6985
+ }
6986
+ const zonesData = metadata.zonesData;
6987
+ const currency = appointment.currency || "CHF";
6988
+ Object.values(zonesData).forEach((items) => {
6989
+ items.forEach((item) => {
6990
+ if (item.type === "item" && item.productId) {
6991
+ const price = item.priceOverrideAmount || item.price || 0;
6992
+ const quantity = item.quantity || 1;
6993
+ const calculatedSubtotal = price * quantity;
6994
+ const storedSubtotal = item.subtotal || 0;
6995
+ const subtotal = Math.abs(storedSubtotal - calculatedSubtotal) < 0.01 ? storedSubtotal : calculatedSubtotal;
6996
+ products.push({
6997
+ productId: item.productId,
6998
+ productName: item.productName || "Unknown Product",
6999
+ brandId: item.productBrandId || "",
7000
+ brandName: item.productBrandName || "",
7001
+ quantity,
7002
+ price,
7003
+ subtotal,
7004
+ currency: item.currency || currency
7005
+ });
7056
7006
  }
7057
- result = newResult;
7007
+ });
7008
+ });
7009
+ return products;
7010
+ }
7011
+
7012
+ // src/services/analytics/utils/time-calculation.utils.ts
7013
+ function calculateTimeEfficiency(appointment) {
7014
+ const startTime = appointment.appointmentStartTime;
7015
+ const endTime = appointment.appointmentEndTime;
7016
+ if (!startTime || !endTime) {
7017
+ return null;
7018
+ }
7019
+ const bookedDurationMs = endTime.toMillis() - startTime.toMillis();
7020
+ const bookedDuration = Math.round(bookedDurationMs / (1e3 * 60));
7021
+ const actualDuration = appointment.actualDurationMinutes || bookedDuration;
7022
+ const efficiency = bookedDuration > 0 ? actualDuration / bookedDuration * 100 : 100;
7023
+ const overrun = actualDuration > bookedDuration ? actualDuration - bookedDuration : 0;
7024
+ const underutilization = bookedDuration > actualDuration ? bookedDuration - actualDuration : 0;
7025
+ return {
7026
+ bookedDuration,
7027
+ actualDuration,
7028
+ efficiency,
7029
+ overrun,
7030
+ underutilization
7031
+ };
7032
+ }
7033
+ function calculateAverageTimeMetrics(appointments) {
7034
+ if (appointments.length === 0) {
7035
+ return {
7036
+ averageBookedDuration: 0,
7037
+ averageActualDuration: 0,
7038
+ averageEfficiency: 0,
7039
+ totalOverrun: 0,
7040
+ totalUnderutilization: 0,
7041
+ averageOverrun: 0,
7042
+ averageUnderutilization: 0,
7043
+ appointmentsWithActualTime: 0
7044
+ };
7045
+ }
7046
+ let totalBookedDuration = 0;
7047
+ let totalActualDuration = 0;
7048
+ let totalOverrun = 0;
7049
+ let totalUnderutilization = 0;
7050
+ let appointmentsWithActualTime = 0;
7051
+ appointments.forEach((appointment) => {
7052
+ const timeData = calculateTimeEfficiency(appointment);
7053
+ if (timeData) {
7054
+ totalBookedDuration += timeData.bookedDuration;
7055
+ totalActualDuration += timeData.actualDuration;
7056
+ totalOverrun += timeData.overrun;
7057
+ totalUnderutilization += timeData.underutilization;
7058
+ if (appointment.actualDurationMinutes !== void 0) {
7059
+ appointmentsWithActualTime++;
7060
+ }
7061
+ }
7062
+ });
7063
+ const count = appointments.length;
7064
+ const averageBookedDuration = count > 0 ? totalBookedDuration / count : 0;
7065
+ const averageActualDuration = count > 0 ? totalActualDuration / count : 0;
7066
+ const averageEfficiency = averageBookedDuration > 0 ? averageActualDuration / averageBookedDuration * 100 : 0;
7067
+ const averageOverrun = count > 0 ? totalOverrun / count : 0;
7068
+ const averageUnderutilization = count > 0 ? totalUnderutilization / count : 0;
7069
+ return {
7070
+ averageBookedDuration: Math.round(averageBookedDuration),
7071
+ averageActualDuration: Math.round(averageActualDuration),
7072
+ averageEfficiency: Math.round(averageEfficiency * 100) / 100,
7073
+ totalOverrun,
7074
+ totalUnderutilization,
7075
+ averageOverrun: Math.round(averageOverrun),
7076
+ averageUnderutilization: Math.round(averageUnderutilization),
7077
+ appointmentsWithActualTime
7078
+ };
7079
+ }
7080
+ function calculateEfficiencyDistribution(appointments) {
7081
+ const ranges = [
7082
+ { label: "0-50%", min: 0, max: 50 },
7083
+ { label: "50-75%", min: 50, max: 75 },
7084
+ { label: "75-100%", min: 75, max: 100 },
7085
+ { label: "100%+", min: 100, max: Infinity }
7086
+ ];
7087
+ const distribution = ranges.map((range) => ({
7088
+ range: range.label,
7089
+ count: 0,
7090
+ percentage: 0
7091
+ }));
7092
+ let validCount = 0;
7093
+ appointments.forEach((appointment) => {
7094
+ const timeData = calculateTimeEfficiency(appointment);
7095
+ if (timeData) {
7096
+ validCount++;
7097
+ const efficiency = timeData.efficiency;
7098
+ for (let i = 0; i < ranges.length; i++) {
7099
+ if (efficiency >= ranges[i].min && efficiency < ranges[i].max) {
7100
+ distribution[i].count++;
7101
+ break;
7102
+ }
7103
+ }
7104
+ }
7105
+ });
7106
+ if (validCount > 0) {
7107
+ distribution.forEach((item) => {
7108
+ item.percentage = Math.round(item.count / validCount * 100 * 100) / 100;
7109
+ });
7110
+ }
7111
+ return distribution;
7112
+ }
7113
+ function calculateCancellationLeadTime(appointment) {
7114
+ if (!appointment.cancellationTime || !appointment.appointmentStartTime) {
7115
+ return null;
7116
+ }
7117
+ const cancellationTime = appointment.cancellationTime.toMillis();
7118
+ const appointmentTime = appointment.appointmentStartTime.toMillis();
7119
+ const diffMs = appointmentTime - cancellationTime;
7120
+ return Math.max(0, diffMs / (1e3 * 60 * 60));
7121
+ }
7122
+
7123
+ // src/services/analytics/utils/appointment-filtering.utils.ts
7124
+ function filterByDateRange(appointments, dateRange) {
7125
+ if (!dateRange) {
7126
+ return appointments;
7127
+ }
7128
+ const startTime = dateRange.start.getTime();
7129
+ const endTime = dateRange.end.getTime();
7130
+ return appointments.filter((appointment) => {
7131
+ const appointmentTime = appointment.appointmentStartTime.toMillis();
7132
+ return appointmentTime >= startTime && appointmentTime <= endTime;
7133
+ });
7134
+ }
7135
+ function filterAppointments(appointments, filters) {
7136
+ if (!filters) {
7137
+ return appointments;
7138
+ }
7139
+ return appointments.filter((appointment) => {
7140
+ if (filters.clinicBranchId && appointment.clinicBranchId !== filters.clinicBranchId) {
7141
+ return false;
7058
7142
  }
7059
- return result;
7143
+ if (filters.practitionerId && appointment.practitionerId !== filters.practitionerId) {
7144
+ return false;
7145
+ }
7146
+ if (filters.procedureId && appointment.procedureId !== filters.procedureId) {
7147
+ return false;
7148
+ }
7149
+ if (filters.patientId && appointment.patientId !== filters.patientId) {
7150
+ return false;
7151
+ }
7152
+ return true;
7153
+ });
7154
+ }
7155
+ function filterByStatus(appointments, statuses) {
7156
+ return appointments.filter((appointment) => statuses.includes(appointment.status));
7157
+ }
7158
+ function getCompletedAppointments(appointments) {
7159
+ return filterByStatus(appointments, ["completed" /* COMPLETED */]);
7160
+ }
7161
+ function getCanceledAppointments(appointments) {
7162
+ return filterByStatus(appointments, [
7163
+ "canceled_patient" /* CANCELED_PATIENT */,
7164
+ "canceled_clinic" /* CANCELED_CLINIC */,
7165
+ "canceled_patient_rescheduled" /* CANCELED_PATIENT_RESCHEDULED */
7166
+ ]);
7167
+ }
7168
+ function getNoShowAppointments(appointments) {
7169
+ return filterByStatus(appointments, ["no_show" /* NO_SHOW */]);
7170
+ }
7171
+ function calculatePercentage(part, total) {
7172
+ if (total === 0) {
7173
+ return 0;
7060
7174
  }
7061
- /**
7062
- * Apply practitioner's specific working hours for the given clinic
7063
- *
7064
- * @param intervals - Current available intervals
7065
- * @param practitioner - Practitioner object
7066
- * @param clinicId - ID of the clinic
7067
- * @param timeframe - Overall timeframe being considered
7068
- * @param tz - IANA timezone of the clinic
7069
- * @returns Intervals filtered by practitioner's working hours
7070
- */
7071
- static applyPractitionerWorkingHours(intervals, practitioner, clinicId, timeframe, tz) {
7072
- if (!intervals.length) return [];
7073
- console.log(`Applying practitioner working hours for clinic ${clinicId}`);
7074
- const clinicWorkingHours = practitioner.clinicWorkingHours.find(
7075
- (hours) => hours.clinicId === clinicId && hours.isActive
7175
+ return Math.round(part / total * 100 * 100) / 100;
7176
+ }
7177
+
7178
+ // src/services/analytics/utils/stored-analytics.utils.ts
7179
+ import { Timestamp, doc, getDoc } from "firebase/firestore";
7180
+ function isAnalyticsDataFresh(computedAt, maxAgeHours) {
7181
+ const now = Timestamp.now();
7182
+ const ageMs = now.toMillis() - computedAt.toMillis();
7183
+ const ageHours = ageMs / (1e3 * 60 * 60);
7184
+ return ageHours <= maxAgeHours;
7185
+ }
7186
+ async function readStoredAnalytics(db, clinicBranchId, subcollection, documentId, period) {
7187
+ try {
7188
+ const docRef = doc(
7189
+ db,
7190
+ CLINICS_COLLECTION,
7191
+ clinicBranchId,
7192
+ ANALYTICS_COLLECTION,
7193
+ subcollection,
7194
+ period,
7195
+ documentId
7076
7196
  );
7077
- if (!clinicWorkingHours) {
7078
- console.log(
7079
- `No working hours found for practitioner at clinic ${clinicId}`
7080
- );
7081
- return [];
7197
+ const docSnap = await getDoc(docRef);
7198
+ if (!docSnap.exists()) {
7199
+ return null;
7082
7200
  }
7083
- const workingIntervals = this.createPractitionerWorkingHoursIntervals(
7084
- clinicWorkingHours.workingHours,
7085
- timeframe.start.toDate(),
7086
- timeframe.end.toDate(),
7087
- tz
7088
- );
7089
- return this.intersectIntervals(intervals, workingIntervals);
7201
+ return docSnap.data();
7202
+ } catch (error) {
7203
+ console.error(`[StoredAnalytics] Error reading ${subcollection}/${period}/${documentId}:`, error);
7204
+ return null;
7205
+ }
7206
+ }
7207
+ async function readStoredPractitionerAnalytics(db, clinicBranchId, practitionerId, options = {}) {
7208
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7209
+ if (!useCache) {
7210
+ return null;
7211
+ }
7212
+ const stored = await readStoredAnalytics(
7213
+ db,
7214
+ clinicBranchId,
7215
+ PRACTITIONER_ANALYTICS_SUBCOLLECTION,
7216
+ practitionerId,
7217
+ period
7218
+ );
7219
+ if (!stored) {
7220
+ return null;
7221
+ }
7222
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7223
+ return null;
7224
+ }
7225
+ return stored;
7226
+ }
7227
+ async function readStoredProcedureAnalytics(db, clinicBranchId, procedureId, options = {}) {
7228
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7229
+ if (!useCache) {
7230
+ return null;
7231
+ }
7232
+ const stored = await readStoredAnalytics(
7233
+ db,
7234
+ clinicBranchId,
7235
+ PROCEDURE_ANALYTICS_SUBCOLLECTION,
7236
+ procedureId,
7237
+ period
7238
+ );
7239
+ if (!stored) {
7240
+ return null;
7241
+ }
7242
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7243
+ return null;
7244
+ }
7245
+ return stored;
7246
+ }
7247
+ async function readStoredDashboardAnalytics(db, clinicBranchId, options = {}) {
7248
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7249
+ if (!useCache) {
7250
+ return null;
7251
+ }
7252
+ const stored = await readStoredAnalytics(
7253
+ db,
7254
+ clinicBranchId,
7255
+ DASHBOARD_ANALYTICS_SUBCOLLECTION,
7256
+ "current",
7257
+ period
7258
+ );
7259
+ if (!stored) {
7260
+ return null;
7261
+ }
7262
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7263
+ return null;
7264
+ }
7265
+ return stored;
7266
+ }
7267
+ async function readStoredTimeEfficiencyMetrics(db, clinicBranchId, options = {}) {
7268
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7269
+ if (!useCache) {
7270
+ return null;
7271
+ }
7272
+ const stored = await readStoredAnalytics(
7273
+ db,
7274
+ clinicBranchId,
7275
+ TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION,
7276
+ "current",
7277
+ period
7278
+ );
7279
+ if (!stored) {
7280
+ return null;
7281
+ }
7282
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7283
+ return null;
7284
+ }
7285
+ return stored;
7286
+ }
7287
+ async function readStoredRevenueMetrics(db, clinicBranchId, options = {}) {
7288
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7289
+ if (!useCache) {
7290
+ return null;
7291
+ }
7292
+ const stored = await readStoredAnalytics(
7293
+ db,
7294
+ clinicBranchId,
7295
+ REVENUE_ANALYTICS_SUBCOLLECTION,
7296
+ "current",
7297
+ period
7298
+ );
7299
+ if (!stored) {
7300
+ return null;
7301
+ }
7302
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7303
+ return null;
7304
+ }
7305
+ return stored;
7306
+ }
7307
+ async function readStoredCancellationMetrics(db, clinicBranchId, entityType, options = {}) {
7308
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7309
+ if (!useCache) {
7310
+ return null;
7311
+ }
7312
+ const stored = await readStoredAnalytics(
7313
+ db,
7314
+ clinicBranchId,
7315
+ CANCELLATION_ANALYTICS_SUBCOLLECTION,
7316
+ entityType,
7317
+ period
7318
+ );
7319
+ if (!stored) {
7320
+ return null;
7321
+ }
7322
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7323
+ return null;
7324
+ }
7325
+ return stored;
7326
+ }
7327
+ async function readStoredNoShowMetrics(db, clinicBranchId, entityType, options = {}) {
7328
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7329
+ if (!useCache) {
7330
+ return null;
7331
+ }
7332
+ const stored = await readStoredAnalytics(
7333
+ db,
7334
+ clinicBranchId,
7335
+ NO_SHOW_ANALYTICS_SUBCOLLECTION,
7336
+ entityType,
7337
+ period
7338
+ );
7339
+ if (!stored) {
7340
+ return null;
7341
+ }
7342
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7343
+ return null;
7344
+ }
7345
+ return stored;
7346
+ }
7347
+
7348
+ // src/services/analytics/utils/grouping.utils.ts
7349
+ function getTechnologyId(appointment) {
7350
+ var _a;
7351
+ return ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
7352
+ }
7353
+ function getTechnologyName(appointment) {
7354
+ var _a, _b;
7355
+ return ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyName) || ((_b = appointment.procedureInfo) == null ? void 0 : _b.technologyName) || "Unknown";
7356
+ }
7357
+ function getEntityName(appointment, entityType) {
7358
+ var _a, _b, _c, _d, _e, _f;
7359
+ switch (entityType) {
7360
+ case "clinic":
7361
+ return ((_a = appointment.clinicInfo) == null ? void 0 : _a.name) || "Unknown";
7362
+ case "practitioner":
7363
+ return ((_b = appointment.practitionerInfo) == null ? void 0 : _b.name) || "Unknown";
7364
+ case "patient":
7365
+ return ((_c = appointment.patientInfo) == null ? void 0 : _c.fullName) || "Unknown";
7366
+ case "procedure":
7367
+ return ((_d = appointment.procedureInfo) == null ? void 0 : _d.name) || "Unknown";
7368
+ case "technology":
7369
+ return ((_e = appointment.procedureExtendedInfo) == null ? void 0 : _e.procedureTechnologyName) || ((_f = appointment.procedureInfo) == null ? void 0 : _f.technologyName) || "Unknown";
7370
+ }
7371
+ }
7372
+ function getEntityId(appointment, entityType) {
7373
+ var _a;
7374
+ switch (entityType) {
7375
+ case "clinic":
7376
+ return appointment.clinicBranchId;
7377
+ case "practitioner":
7378
+ return appointment.practitionerId;
7379
+ case "patient":
7380
+ return appointment.patientId;
7381
+ case "procedure":
7382
+ return appointment.procedureId;
7383
+ case "technology":
7384
+ return ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
7385
+ }
7386
+ }
7387
+ function groupAppointmentsByEntity(appointments, entityType) {
7388
+ const entityMap = /* @__PURE__ */ new Map();
7389
+ appointments.forEach((appointment) => {
7390
+ let entityId;
7391
+ let entityName;
7392
+ if (entityType === "technology") {
7393
+ entityId = getTechnologyId(appointment);
7394
+ entityName = getTechnologyName(appointment);
7395
+ } else {
7396
+ entityId = getEntityId(appointment, entityType);
7397
+ entityName = getEntityName(appointment, entityType);
7398
+ }
7399
+ if (!entityMap.has(entityId)) {
7400
+ entityMap.set(entityId, { name: entityName, appointments: [] });
7401
+ }
7402
+ entityMap.get(entityId).appointments.push(appointment);
7403
+ });
7404
+ return entityMap;
7405
+ }
7406
+ function calculateGroupedRevenueMetrics(appointments, entityType) {
7407
+ const entityMap = groupAppointmentsByEntity(appointments, entityType);
7408
+ const completed = getCompletedAppointments(appointments);
7409
+ return Array.from(entityMap.entries()).map(([entityId, data]) => {
7410
+ var _a;
7411
+ const entityAppointments = data.appointments;
7412
+ const entityCompleted = entityAppointments.filter(
7413
+ (a) => completed.some((c) => c.id === a.id)
7414
+ );
7415
+ const { totalRevenue, currency } = calculateTotalRevenue(entityCompleted);
7416
+ let totalTax = 0;
7417
+ let totalSubtotal = 0;
7418
+ let unpaidRevenue = 0;
7419
+ let refundedRevenue = 0;
7420
+ entityCompleted.forEach((appointment) => {
7421
+ const costData = calculateAppointmentCost(appointment);
7422
+ if (costData.source === "finalbilling") {
7423
+ totalTax += costData.tax || 0;
7424
+ totalSubtotal += costData.subtotal || 0;
7425
+ } else {
7426
+ totalSubtotal += costData.cost;
7427
+ }
7428
+ if (appointment.paymentStatus === "unpaid") {
7429
+ unpaidRevenue += costData.cost;
7430
+ } else if (appointment.paymentStatus === "refunded") {
7431
+ refundedRevenue += costData.cost;
7432
+ }
7433
+ });
7434
+ let practitionerId;
7435
+ let practitionerName;
7436
+ if (entityType === "procedure" && entityAppointments.length > 0) {
7437
+ const firstAppointment = entityAppointments[0];
7438
+ practitionerId = firstAppointment.practitionerId;
7439
+ practitionerName = (_a = firstAppointment.practitionerInfo) == null ? void 0 : _a.name;
7440
+ }
7441
+ return {
7442
+ entityId,
7443
+ entityName: data.name,
7444
+ entityType,
7445
+ totalRevenue,
7446
+ averageRevenuePerAppointment: entityCompleted.length > 0 ? totalRevenue / entityCompleted.length : 0,
7447
+ totalAppointments: entityAppointments.length,
7448
+ completedAppointments: entityCompleted.length,
7449
+ currency,
7450
+ unpaidRevenue,
7451
+ refundedRevenue,
7452
+ totalTax,
7453
+ totalSubtotal,
7454
+ ...practitionerId && { practitionerId },
7455
+ ...practitionerName && { practitionerName }
7456
+ };
7457
+ });
7458
+ }
7459
+ function calculateGroupedProductUsageMetrics(appointments, entityType) {
7460
+ const entityMap = groupAppointmentsByEntity(appointments, entityType);
7461
+ const completed = getCompletedAppointments(appointments);
7462
+ return Array.from(entityMap.entries()).map(([entityId, data]) => {
7463
+ var _a;
7464
+ const entityAppointments = data.appointments;
7465
+ const entityCompleted = entityAppointments.filter(
7466
+ (a) => completed.some((c) => c.id === a.id)
7467
+ );
7468
+ const productMap = /* @__PURE__ */ new Map();
7469
+ entityCompleted.forEach((appointment) => {
7470
+ const products = extractProductUsage(appointment);
7471
+ products.forEach((product) => {
7472
+ if (productMap.has(product.productId)) {
7473
+ const existing = productMap.get(product.productId);
7474
+ existing.quantity += product.quantity;
7475
+ existing.revenue += product.subtotal;
7476
+ existing.usageCount++;
7477
+ } else {
7478
+ productMap.set(product.productId, {
7479
+ name: product.productName,
7480
+ brandName: product.brandName,
7481
+ quantity: product.quantity,
7482
+ revenue: product.subtotal,
7483
+ usageCount: 1
7484
+ });
7485
+ }
7486
+ });
7487
+ });
7488
+ const topProducts = Array.from(productMap.entries()).map(([productId, productData]) => ({
7489
+ productId,
7490
+ productName: productData.name,
7491
+ brandName: productData.brandName,
7492
+ totalQuantity: productData.quantity,
7493
+ totalRevenue: productData.revenue,
7494
+ usageCount: productData.usageCount
7495
+ })).sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, 10);
7496
+ const totalProductRevenue = topProducts.reduce((sum, p) => sum + p.totalRevenue, 0);
7497
+ const totalProductQuantity = topProducts.reduce((sum, p) => sum + p.totalQuantity, 0);
7498
+ let practitionerId;
7499
+ let practitionerName;
7500
+ if (entityType === "procedure" && entityAppointments.length > 0) {
7501
+ const firstAppointment = entityAppointments[0];
7502
+ practitionerId = firstAppointment.practitionerId;
7503
+ practitionerName = (_a = firstAppointment.practitionerInfo) == null ? void 0 : _a.name;
7504
+ }
7505
+ return {
7506
+ entityId,
7507
+ entityName: data.name,
7508
+ entityType,
7509
+ totalProductsUsed: productMap.size,
7510
+ uniqueProducts: productMap.size,
7511
+ totalProductRevenue,
7512
+ totalProductQuantity,
7513
+ averageProductsPerAppointment: entityCompleted.length > 0 ? productMap.size / entityCompleted.length : 0,
7514
+ topProducts,
7515
+ ...practitionerId && { practitionerId },
7516
+ ...practitionerName && { practitionerName }
7517
+ };
7518
+ });
7519
+ }
7520
+ function calculateGroupedTimeEfficiencyMetrics(appointments, entityType) {
7521
+ const entityMap = groupAppointmentsByEntity(appointments, entityType);
7522
+ const completed = getCompletedAppointments(appointments);
7523
+ return Array.from(entityMap.entries()).map(([entityId, data]) => {
7524
+ var _a;
7525
+ const entityAppointments = data.appointments;
7526
+ const entityCompleted = entityAppointments.filter(
7527
+ (a) => completed.some((c) => c.id === a.id)
7528
+ );
7529
+ const timeMetrics = calculateAverageTimeMetrics(entityCompleted);
7530
+ let practitionerId;
7531
+ let practitionerName;
7532
+ if (entityType === "procedure" && entityAppointments.length > 0) {
7533
+ const firstAppointment = entityAppointments[0];
7534
+ practitionerId = firstAppointment.practitionerId;
7535
+ practitionerName = (_a = firstAppointment.practitionerInfo) == null ? void 0 : _a.name;
7536
+ }
7537
+ return {
7538
+ entityId,
7539
+ entityName: data.name,
7540
+ entityType,
7541
+ totalAppointments: entityCompleted.length,
7542
+ appointmentsWithActualTime: timeMetrics.appointmentsWithActualTime,
7543
+ averageBookedDuration: timeMetrics.averageBookedDuration,
7544
+ averageActualDuration: timeMetrics.averageActualDuration,
7545
+ averageEfficiency: timeMetrics.averageEfficiency,
7546
+ totalOverrun: timeMetrics.totalOverrun,
7547
+ totalUnderutilization: timeMetrics.totalUnderutilization,
7548
+ averageOverrun: timeMetrics.averageOverrun,
7549
+ averageUnderutilization: timeMetrics.averageUnderutilization,
7550
+ ...practitionerId && { practitionerId },
7551
+ ...practitionerName && { practitionerName }
7552
+ };
7553
+ });
7554
+ }
7555
+ function calculateGroupedPatientBehaviorMetrics(appointments, entityType) {
7556
+ const entityMap = groupAppointmentsByEntity(appointments, entityType);
7557
+ const canceled = getCanceledAppointments(appointments);
7558
+ const noShow = getNoShowAppointments(appointments);
7559
+ return Array.from(entityMap.entries()).map(([entityId, data]) => {
7560
+ const entityAppointments = data.appointments;
7561
+ const patientMap = /* @__PURE__ */ new Map();
7562
+ entityAppointments.forEach((appointment) => {
7563
+ var _a;
7564
+ const patientId = appointment.patientId;
7565
+ const patientName = ((_a = appointment.patientInfo) == null ? void 0 : _a.fullName) || "Unknown";
7566
+ if (!patientMap.has(patientId)) {
7567
+ patientMap.set(patientId, {
7568
+ name: patientName,
7569
+ appointments: [],
7570
+ noShows: [],
7571
+ cancellations: []
7572
+ });
7573
+ }
7574
+ const patientData = patientMap.get(patientId);
7575
+ patientData.appointments.push(appointment);
7576
+ if (noShow.some((ns) => ns.id === appointment.id)) {
7577
+ patientData.noShows.push(appointment);
7578
+ }
7579
+ if (canceled.some((c) => c.id === appointment.id)) {
7580
+ patientData.cancellations.push(appointment);
7581
+ }
7582
+ });
7583
+ const patientMetrics = Array.from(patientMap.entries()).map(([patientId, patientData]) => ({
7584
+ patientId,
7585
+ patientName: patientData.name,
7586
+ noShowCount: patientData.noShows.length,
7587
+ cancellationCount: patientData.cancellations.length,
7588
+ totalAppointments: patientData.appointments.length,
7589
+ noShowRate: calculatePercentage(
7590
+ patientData.noShows.length,
7591
+ patientData.appointments.length
7592
+ ),
7593
+ cancellationRate: calculatePercentage(
7594
+ patientData.cancellations.length,
7595
+ patientData.appointments.length
7596
+ )
7597
+ }));
7598
+ const patientsWithNoShows = patientMetrics.filter((p) => p.noShowCount > 0).length;
7599
+ const patientsWithCancellations = patientMetrics.filter((p) => p.cancellationCount > 0).length;
7600
+ const averageNoShowRate = patientMetrics.length > 0 ? patientMetrics.reduce((sum, p) => sum + p.noShowRate, 0) / patientMetrics.length : 0;
7601
+ const averageCancellationRate = patientMetrics.length > 0 ? patientMetrics.reduce((sum, p) => sum + p.cancellationRate, 0) / patientMetrics.length : 0;
7602
+ const topNoShowPatients = patientMetrics.filter((p) => p.noShowCount > 0).sort((a, b) => b.noShowRate - a.noShowRate).slice(0, 10).map((p) => ({
7603
+ patientId: p.patientId,
7604
+ patientName: p.patientName,
7605
+ noShowCount: p.noShowCount,
7606
+ totalAppointments: p.totalAppointments,
7607
+ noShowRate: p.noShowRate
7608
+ }));
7609
+ const topCancellationPatients = patientMetrics.filter((p) => p.cancellationCount > 0).sort((a, b) => b.cancellationRate - a.cancellationRate).slice(0, 10).map((p) => ({
7610
+ patientId: p.patientId,
7611
+ patientName: p.patientName,
7612
+ cancellationCount: p.cancellationCount,
7613
+ totalAppointments: p.totalAppointments,
7614
+ cancellationRate: p.cancellationRate
7615
+ }));
7616
+ const newPatients = patientMetrics.filter((p) => p.totalAppointments === 1).length;
7617
+ const returningPatients = patientMetrics.filter((p) => p.totalAppointments > 1).length;
7618
+ return {
7619
+ entityId,
7620
+ entityName: data.name,
7621
+ entityType,
7622
+ totalPatients: patientMap.size,
7623
+ patientsWithNoShows,
7624
+ patientsWithCancellations,
7625
+ averageNoShowRate: Math.round(averageNoShowRate * 100) / 100,
7626
+ averageCancellationRate: Math.round(averageCancellationRate * 100) / 100,
7627
+ topNoShowPatients,
7628
+ topCancellationPatients
7629
+ };
7630
+ });
7631
+ }
7632
+
7633
+ // src/services/analytics/utils/trend-calculation.utils.ts
7634
+ function getPeriodDates(date, period) {
7635
+ const year = date.getFullYear();
7636
+ const month = date.getMonth();
7637
+ const day = date.getDate();
7638
+ let startDate;
7639
+ let endDate;
7640
+ let periodString;
7641
+ switch (period) {
7642
+ case "week": {
7643
+ const dayOfWeek = date.getDay();
7644
+ const diff = date.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1);
7645
+ startDate = new Date(year, month, diff);
7646
+ startDate.setHours(0, 0, 0, 0);
7647
+ endDate = new Date(startDate);
7648
+ endDate.setDate(endDate.getDate() + 6);
7649
+ endDate.setHours(23, 59, 59, 999);
7650
+ const weekNumber = getWeekNumber(date);
7651
+ periodString = `${year}-W${weekNumber.toString().padStart(2, "0")}`;
7652
+ break;
7653
+ }
7654
+ case "month": {
7655
+ startDate = new Date(year, month, 1);
7656
+ startDate.setHours(0, 0, 0, 0);
7657
+ endDate = new Date(year, month + 1, 0);
7658
+ endDate.setHours(23, 59, 59, 999);
7659
+ periodString = `${year}-${(month + 1).toString().padStart(2, "0")}`;
7660
+ break;
7661
+ }
7662
+ case "quarter": {
7663
+ const quarter = Math.floor(month / 3);
7664
+ const quarterStartMonth = quarter * 3;
7665
+ startDate = new Date(year, quarterStartMonth, 1);
7666
+ startDate.setHours(0, 0, 0, 0);
7667
+ endDate = new Date(year, quarterStartMonth + 3, 0);
7668
+ endDate.setHours(23, 59, 59, 999);
7669
+ periodString = `${year}-Q${quarter + 1}`;
7670
+ break;
7671
+ }
7672
+ case "year": {
7673
+ startDate = new Date(year, 0, 1);
7674
+ startDate.setHours(0, 0, 0, 0);
7675
+ endDate = new Date(year, 11, 31);
7676
+ endDate.setHours(23, 59, 59, 999);
7677
+ periodString = `${year}`;
7678
+ break;
7679
+ }
7680
+ }
7681
+ return {
7682
+ period: periodString,
7683
+ startDate,
7684
+ endDate
7685
+ };
7686
+ }
7687
+ function getWeekNumber(date) {
7688
+ const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
7689
+ const dayNum = d.getUTCDay() || 7;
7690
+ d.setUTCDate(d.getUTCDate() + 4 - dayNum);
7691
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
7692
+ return Math.ceil(((d.getTime() - yearStart.getTime()) / 864e5 + 1) / 7);
7693
+ }
7694
+ function groupAppointmentsByPeriod(appointments, period) {
7695
+ const periodMap = /* @__PURE__ */ new Map();
7696
+ appointments.forEach((appointment) => {
7697
+ const appointmentDate = appointment.appointmentStartTime.toDate();
7698
+ const periodInfo = getPeriodDates(appointmentDate, period);
7699
+ const periodKey = periodInfo.period;
7700
+ if (!periodMap.has(periodKey)) {
7701
+ periodMap.set(periodKey, []);
7702
+ }
7703
+ periodMap.get(periodKey).push(appointment);
7704
+ });
7705
+ return periodMap;
7706
+ }
7707
+ function generatePeriods(startDate, endDate, period) {
7708
+ const periods = [];
7709
+ const current = new Date(startDate);
7710
+ while (current <= endDate) {
7711
+ const periodInfo = getPeriodDates(current, period);
7712
+ if (periodInfo.endDate >= startDate && periodInfo.startDate <= endDate) {
7713
+ periods.push(periodInfo);
7714
+ }
7715
+ switch (period) {
7716
+ case "week":
7717
+ current.setDate(current.getDate() + 7);
7718
+ break;
7719
+ case "month":
7720
+ current.setMonth(current.getMonth() + 1);
7721
+ break;
7722
+ case "quarter":
7723
+ current.setMonth(current.getMonth() + 3);
7724
+ break;
7725
+ case "year":
7726
+ current.setFullYear(current.getFullYear() + 1);
7727
+ break;
7728
+ }
7729
+ }
7730
+ return periods.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
7731
+ }
7732
+ function calculatePercentageChange(current, previous) {
7733
+ if (previous === 0) {
7734
+ return current > 0 ? 100 : 0;
7735
+ }
7736
+ return (current - previous) / previous * 100;
7737
+ }
7738
+ function getTrendChange(current, previous) {
7739
+ const percentageChange = calculatePercentageChange(current, previous);
7740
+ const direction = percentageChange > 0.01 ? "up" : percentageChange < -0.01 ? "down" : "stable";
7741
+ return {
7742
+ value: current,
7743
+ previousValue: previous,
7744
+ percentageChange: Math.abs(percentageChange),
7745
+ direction
7746
+ };
7747
+ }
7748
+
7749
+ // src/services/analytics/review-analytics.service.ts
7750
+ import { collection, query, where, getDocs, getDoc as getDoc2, doc as doc2, Timestamp as Timestamp2 } from "firebase/firestore";
7751
+ var ReviewAnalyticsService = class extends BaseService {
7752
+ constructor(db, auth, app, appointmentService) {
7753
+ super(db, auth, app);
7754
+ this.appointmentService = appointmentService;
7755
+ }
7756
+ /**
7757
+ * Fetches reviews filtered by date range and optional filters
7758
+ * Properly filters by clinic branch by checking appointment's clinicId
7759
+ */
7760
+ async fetchReviews(dateRange, filters) {
7761
+ let q = query(collection(this.db, REVIEWS_COLLECTION));
7762
+ if (dateRange) {
7763
+ const startTimestamp = Timestamp2.fromDate(dateRange.start);
7764
+ const endTimestamp = Timestamp2.fromDate(dateRange.end);
7765
+ q = query(q, where("createdAt", ">=", startTimestamp), where("createdAt", "<=", endTimestamp));
7766
+ }
7767
+ const snapshot = await getDocs(q);
7768
+ const reviews = snapshot.docs.map((doc3) => {
7769
+ var _a, _b;
7770
+ const data = doc3.data();
7771
+ return {
7772
+ ...data,
7773
+ id: doc3.id,
7774
+ createdAt: ((_a = data.createdAt) == null ? void 0 : _a.toDate) ? data.createdAt.toDate() : new Date(data.createdAt),
7775
+ updatedAt: ((_b = data.updatedAt) == null ? void 0 : _b.toDate) ? data.updatedAt.toDate() : new Date(data.updatedAt)
7776
+ };
7777
+ });
7778
+ console.log(`[ReviewAnalytics] Fetched ${reviews.length} reviews in date range`);
7779
+ if ((filters == null ? void 0 : filters.clinicBranchId) && reviews.length > 0) {
7780
+ const appointmentIds = [...new Set(reviews.map((r) => r.appointmentId))];
7781
+ console.log(`[ReviewAnalytics] Filtering by clinic ${filters.clinicBranchId}, checking ${appointmentIds.length} appointments`);
7782
+ const validAppointmentIds = /* @__PURE__ */ new Set();
7783
+ for (let i = 0; i < appointmentIds.length; i += 10) {
7784
+ const batch = appointmentIds.slice(i, i + 10);
7785
+ const appointmentsQuery = query(
7786
+ collection(this.db, APPOINTMENTS_COLLECTION),
7787
+ where("id", "in", batch)
7788
+ );
7789
+ const appointmentSnapshot = await getDocs(appointmentsQuery);
7790
+ appointmentSnapshot.docs.forEach((doc3) => {
7791
+ const appointment = doc3.data();
7792
+ if (appointment.clinicBranchId === filters.clinicBranchId) {
7793
+ validAppointmentIds.add(doc3.id);
7794
+ }
7795
+ });
7796
+ }
7797
+ const filteredReviews = reviews.filter((review) => validAppointmentIds.has(review.appointmentId));
7798
+ console.log(`[ReviewAnalytics] After clinic filter: ${filteredReviews.length} reviews (from ${validAppointmentIds.size} valid appointments)`);
7799
+ return filteredReviews;
7800
+ }
7801
+ return reviews;
7802
+ }
7803
+ /**
7804
+ * Gets review metrics for a specific entity
7805
+ */
7806
+ async getReviewMetricsByEntity(entityType, entityId, dateRange, filters) {
7807
+ const reviews = await this.fetchReviews(dateRange, filters);
7808
+ let relevantReviews = [];
7809
+ if (entityType === "practitioner") {
7810
+ relevantReviews = reviews.filter((r) => {
7811
+ var _a;
7812
+ return ((_a = r.practitionerReview) == null ? void 0 : _a.practitionerId) === entityId;
7813
+ });
7814
+ } else if (entityType === "procedure") {
7815
+ relevantReviews = reviews.filter((r) => {
7816
+ var _a;
7817
+ return ((_a = r.procedureReview) == null ? void 0 : _a.procedureId) === entityId;
7818
+ });
7819
+ } else if (entityType === "category" || entityType === "subcategory") {
7820
+ relevantReviews = reviews;
7821
+ }
7822
+ if (relevantReviews.length === 0) {
7823
+ return null;
7824
+ }
7825
+ return this.calculateReviewMetrics(relevantReviews, entityType, entityId);
7826
+ }
7827
+ /**
7828
+ * Gets review metrics for multiple entities (grouped)
7829
+ */
7830
+ async getReviewMetricsByEntities(entityType, dateRange, filters) {
7831
+ const reviews = await this.fetchReviews(dateRange, filters);
7832
+ const entityMap = /* @__PURE__ */ new Map();
7833
+ let practitionerNameMap = null;
7834
+ let procedureNameMap = null;
7835
+ let procedureToTechnologyMap = null;
7836
+ if (entityType === "practitioner" || entityType === "procedure" || entityType === "technology") {
7837
+ if (!this.appointmentService) {
7838
+ console.warn(`[ReviewAnalytics] AppointmentService not available for ${entityType} name resolution`);
7839
+ return [];
7840
+ }
7841
+ console.log(`[ReviewAnalytics] Grouping by ${entityType}, fetching appointments for name resolution...`);
7842
+ const searchParams = {
7843
+ ...filters
7844
+ };
7845
+ if (dateRange) {
7846
+ searchParams.startDate = dateRange.start;
7847
+ searchParams.endDate = dateRange.end;
7848
+ }
7849
+ const appointmentsResult = await this.appointmentService.searchAppointments(searchParams);
7850
+ const appointments = appointmentsResult.appointments || [];
7851
+ console.log(`[ReviewAnalytics] Found ${appointments.length} appointments for name resolution`);
7852
+ practitionerNameMap = /* @__PURE__ */ new Map();
7853
+ procedureNameMap = /* @__PURE__ */ new Map();
7854
+ procedureToTechnologyMap = /* @__PURE__ */ new Map();
7855
+ appointments.forEach((appointment) => {
7856
+ var _a, _b, _c, _d, _e, _f;
7857
+ if (appointment.practitionerId && ((_a = appointment.practitionerInfo) == null ? void 0 : _a.name)) {
7858
+ practitionerNameMap.set(appointment.practitionerId, appointment.practitionerInfo.name);
7859
+ }
7860
+ if (appointment.procedureId) {
7861
+ if ((_b = appointment.procedureInfo) == null ? void 0 : _b.name) {
7862
+ procedureNameMap.set(appointment.procedureId, appointment.procedureInfo.name);
7863
+ }
7864
+ const mainTechnologyId = ((_c = appointment.procedureExtendedInfo) == null ? void 0 : _c.procedureTechnologyId) || "unknown-technology";
7865
+ const mainTechnologyName = ((_d = appointment.procedureExtendedInfo) == null ? void 0 : _d.procedureTechnologyName) || ((_e = appointment.procedureInfo) == null ? void 0 : _e.name) || "Unknown Technology";
7866
+ procedureToTechnologyMap.set(appointment.procedureId, {
7867
+ id: mainTechnologyId,
7868
+ name: mainTechnologyName
7869
+ });
7870
+ }
7871
+ if ((_f = appointment.metadata) == null ? void 0 : _f.extendedProcedures) {
7872
+ appointment.metadata.extendedProcedures.forEach((extendedProc) => {
7873
+ if (extendedProc.procedureId) {
7874
+ if (extendedProc.procedureName) {
7875
+ procedureNameMap.set(extendedProc.procedureId, extendedProc.procedureName);
7876
+ }
7877
+ const extTechnologyId = extendedProc.procedureTechnologyId || "unknown-technology";
7878
+ const extTechnologyName = extendedProc.procedureTechnologyName || "Unknown Technology";
7879
+ procedureToTechnologyMap.set(extendedProc.procedureId, {
7880
+ id: extTechnologyId,
7881
+ name: extTechnologyName
7882
+ });
7883
+ }
7884
+ });
7885
+ }
7886
+ });
7887
+ console.log(`[ReviewAnalytics] Built name maps: ${practitionerNameMap.size} practitioners, ${procedureNameMap.size} procedures, ${procedureToTechnologyMap.size} technologies`);
7888
+ }
7889
+ if (entityType === "technology" && procedureToTechnologyMap) {
7890
+ let processedReviewCount = 0;
7891
+ reviews.forEach((review) => {
7892
+ var _a;
7893
+ if ((_a = review.procedureReview) == null ? void 0 : _a.procedureId) {
7894
+ const techInfo = procedureToTechnologyMap.get(review.procedureReview.procedureId);
7895
+ if (techInfo) {
7896
+ if (!entityMap.has(techInfo.id)) {
7897
+ entityMap.set(techInfo.id, { reviews: [], name: techInfo.name });
7898
+ }
7899
+ entityMap.get(techInfo.id).reviews.push(review);
7900
+ processedReviewCount++;
7901
+ }
7902
+ }
7903
+ if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
7904
+ review.extendedProcedureReviews.forEach((extendedReview) => {
7905
+ if (extendedReview.procedureId) {
7906
+ const techInfo = procedureToTechnologyMap.get(extendedReview.procedureId);
7907
+ if (techInfo) {
7908
+ if (!entityMap.has(techInfo.id)) {
7909
+ entityMap.set(techInfo.id, { reviews: [], name: techInfo.name });
7910
+ }
7911
+ const reviewWithExtendedOnly = {
7912
+ ...review,
7913
+ procedureReview: extendedReview,
7914
+ extendedProcedureReviews: void 0
7915
+ };
7916
+ entityMap.get(techInfo.id).reviews.push(reviewWithExtendedOnly);
7917
+ processedReviewCount++;
7918
+ }
7919
+ }
7920
+ });
7921
+ }
7922
+ });
7923
+ console.log(`[ReviewAnalytics] Processed ${processedReviewCount} procedure reviews into ${entityMap.size} technology groups`);
7924
+ entityMap.forEach((data, techId) => {
7925
+ console.log(`[ReviewAnalytics] - ${data.name} (${techId}): ${data.reviews.length} reviews`);
7926
+ });
7927
+ } else if (entityType === "procedure" && procedureNameMap) {
7928
+ let processedReviewCount = 0;
7929
+ reviews.forEach((review) => {
7930
+ if (review.procedureReview) {
7931
+ const procedureId = review.procedureReview.procedureId;
7932
+ const procedureName = procedureId && procedureNameMap.get(procedureId) || review.procedureReview.procedureName || "Unknown Procedure";
7933
+ if (procedureId) {
7934
+ if (!entityMap.has(procedureId)) {
7935
+ entityMap.set(procedureId, { reviews: [], name: procedureName });
7936
+ }
7937
+ entityMap.get(procedureId).reviews.push(review);
7938
+ processedReviewCount++;
7939
+ }
7940
+ }
7941
+ if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
7942
+ review.extendedProcedureReviews.forEach((extendedReview) => {
7943
+ const procedureId = extendedReview.procedureId;
7944
+ const procedureName = procedureId && procedureNameMap.get(procedureId) || extendedReview.procedureName || "Unknown Procedure";
7945
+ if (procedureId) {
7946
+ if (!entityMap.has(procedureId)) {
7947
+ entityMap.set(procedureId, { reviews: [], name: procedureName });
7948
+ }
7949
+ const reviewWithExtendedOnly = {
7950
+ ...review,
7951
+ procedureReview: extendedReview,
7952
+ extendedProcedureReviews: void 0
7953
+ };
7954
+ entityMap.get(procedureId).reviews.push(reviewWithExtendedOnly);
7955
+ processedReviewCount++;
7956
+ }
7957
+ });
7958
+ }
7959
+ });
7960
+ console.log(`[ReviewAnalytics] Processed ${processedReviewCount} procedure reviews into ${entityMap.size} procedure groups`);
7961
+ entityMap.forEach((data, procId) => {
7962
+ console.log(`[ReviewAnalytics] - ${data.name} (${procId}): ${data.reviews.length} reviews`);
7963
+ });
7964
+ } else if (entityType === "practitioner" && practitionerNameMap) {
7965
+ reviews.forEach((review) => {
7966
+ if (review.practitionerReview) {
7967
+ const practitionerId = review.practitionerReview.practitionerId;
7968
+ const practitionerName = practitionerId && practitionerNameMap.get(practitionerId) || review.practitionerReview.practitionerName || "Unknown Practitioner";
7969
+ if (practitionerId) {
7970
+ if (!entityMap.has(practitionerId)) {
7971
+ entityMap.set(practitionerId, { reviews: [], name: practitionerName });
7972
+ }
7973
+ entityMap.get(practitionerId).reviews.push(review);
7974
+ }
7975
+ }
7976
+ });
7977
+ console.log(`[ReviewAnalytics] Processed ${reviews.length} reviews into ${entityMap.size} practitioner groups`);
7978
+ entityMap.forEach((data, practId) => {
7979
+ console.log(`[ReviewAnalytics] - ${data.name} (${practId}): ${data.reviews.length} reviews`);
7980
+ });
7981
+ } else {
7982
+ reviews.forEach((review) => {
7983
+ let entityId;
7984
+ let entityName;
7985
+ if (entityId) {
7986
+ if (!entityMap.has(entityId)) {
7987
+ entityMap.set(entityId, { reviews: [], name: entityName || entityId });
7988
+ }
7989
+ entityMap.get(entityId).reviews.push(review);
7990
+ }
7991
+ });
7992
+ }
7993
+ const metrics = [];
7994
+ for (const [entityId, data] of entityMap.entries()) {
7995
+ const metric = this.calculateReviewMetrics(data.reviews, entityType, entityId);
7996
+ if (metric) {
7997
+ metric.entityName = data.name;
7998
+ metrics.push(metric);
7999
+ }
8000
+ }
8001
+ return metrics;
8002
+ }
8003
+ /**
8004
+ * Calculates review metrics from a list of reviews
8005
+ */
8006
+ calculateReviewMetrics(reviews, entityType, entityId) {
8007
+ if (reviews.length === 0) {
8008
+ return null;
8009
+ }
8010
+ let totalRating = 0;
8011
+ let recommendationCount = 0;
8012
+ let practitionerMetrics;
8013
+ let procedureMetrics;
8014
+ let entityName = entityId;
8015
+ if (entityType === "practitioner") {
8016
+ const practitionerReviews = reviews.filter((r) => r.practitionerReview).map((r) => r.practitionerReview);
8017
+ if (practitionerReviews.length === 0) {
8018
+ return null;
8019
+ }
8020
+ entityName = practitionerReviews[0].practitionerName || entityId;
8021
+ totalRating = practitionerReviews.reduce((sum, r) => sum + r.overallRating, 0);
8022
+ recommendationCount = practitionerReviews.filter((r) => r.wouldRecommend).length;
8023
+ practitionerMetrics = {
8024
+ averageKnowledgeAndExpertise: this.calculateAverage(practitionerReviews.map((r) => r.knowledgeAndExpertise)),
8025
+ averageCommunicationSkills: this.calculateAverage(practitionerReviews.map((r) => r.communicationSkills)),
8026
+ averageBedSideManner: this.calculateAverage(practitionerReviews.map((r) => r.bedSideManner)),
8027
+ averageThoroughness: this.calculateAverage(practitionerReviews.map((r) => r.thoroughness)),
8028
+ averageTrustworthiness: this.calculateAverage(practitionerReviews.map((r) => r.trustworthiness))
8029
+ };
8030
+ } else if (entityType === "procedure" || entityType === "technology") {
8031
+ const procedureReviews = reviews.filter((r) => r.procedureReview).map((r) => r.procedureReview);
8032
+ if (procedureReviews.length === 0) {
8033
+ return null;
8034
+ }
8035
+ if (entityType === "procedure") {
8036
+ entityName = procedureReviews[0].procedureName || entityId;
8037
+ }
8038
+ totalRating = procedureReviews.reduce((sum, r) => sum + r.overallRating, 0);
8039
+ recommendationCount = procedureReviews.filter((r) => r.wouldRecommend).length;
8040
+ procedureMetrics = {
8041
+ averageEffectiveness: this.calculateAverage(procedureReviews.map((r) => r.effectivenessOfTreatment)),
8042
+ averageOutcomeExplanation: this.calculateAverage(procedureReviews.map((r) => r.outcomeExplanation)),
8043
+ averagePainManagement: this.calculateAverage(procedureReviews.map((r) => r.painManagement)),
8044
+ averageFollowUpCare: this.calculateAverage(procedureReviews.map((r) => r.followUpCare)),
8045
+ averageValueForMoney: this.calculateAverage(procedureReviews.map((r) => r.valueForMoney))
8046
+ };
8047
+ }
8048
+ const averageRating = totalRating / reviews.length;
8049
+ const recommendationRate = recommendationCount / reviews.length * 100;
8050
+ const result = {
8051
+ entityId,
8052
+ entityName,
8053
+ entityType,
8054
+ totalReviews: reviews.length,
8055
+ averageRating,
8056
+ recommendationRate,
8057
+ practitionerMetrics,
8058
+ procedureMetrics,
8059
+ comparisonToOverall: {
8060
+ ratingDifference: 0,
8061
+ // Will be calculated when comparing to overall
8062
+ recommendationDifference: 0
8063
+ }
8064
+ };
8065
+ return result;
8066
+ }
8067
+ /**
8068
+ * Gets overall review averages for comparison
8069
+ */
8070
+ async getOverallReviewAverages(dateRange, filters) {
8071
+ const reviews = await this.fetchReviews(dateRange, filters);
8072
+ const practitionerReviews = reviews.filter((r) => r.practitionerReview).map((r) => r.practitionerReview);
8073
+ const procedureReviews = reviews.filter((r) => r.procedureReview).map((r) => r.procedureReview);
8074
+ return {
8075
+ practitionerAverage: {
8076
+ totalReviews: practitionerReviews.length,
8077
+ averageRating: practitionerReviews.length > 0 ? this.calculateAverage(practitionerReviews.map((r) => r.overallRating)) : 0,
8078
+ recommendationRate: practitionerReviews.length > 0 ? practitionerReviews.filter((r) => r.wouldRecommend).length / practitionerReviews.length * 100 : 0,
8079
+ averageKnowledgeAndExpertise: this.calculateAverage(practitionerReviews.map((r) => r.knowledgeAndExpertise)),
8080
+ averageCommunicationSkills: this.calculateAverage(practitionerReviews.map((r) => r.communicationSkills)),
8081
+ averageBedSideManner: this.calculateAverage(practitionerReviews.map((r) => r.bedSideManner)),
8082
+ averageThoroughness: this.calculateAverage(practitionerReviews.map((r) => r.thoroughness)),
8083
+ averageTrustworthiness: this.calculateAverage(practitionerReviews.map((r) => r.trustworthiness))
8084
+ },
8085
+ procedureAverage: {
8086
+ totalReviews: procedureReviews.length,
8087
+ averageRating: procedureReviews.length > 0 ? this.calculateAverage(procedureReviews.map((r) => r.overallRating)) : 0,
8088
+ recommendationRate: procedureReviews.length > 0 ? procedureReviews.filter((r) => r.wouldRecommend).length / procedureReviews.length * 100 : 0,
8089
+ averageEffectiveness: this.calculateAverage(procedureReviews.map((r) => r.effectivenessOfTreatment)),
8090
+ averageOutcomeExplanation: this.calculateAverage(procedureReviews.map((r) => r.outcomeExplanation)),
8091
+ averagePainManagement: this.calculateAverage(procedureReviews.map((r) => r.painManagement)),
8092
+ averageFollowUpCare: this.calculateAverage(procedureReviews.map((r) => r.followUpCare)),
8093
+ averageValueForMoney: this.calculateAverage(procedureReviews.map((r) => r.valueForMoney))
8094
+ }
8095
+ };
8096
+ }
8097
+ /**
8098
+ * Gets review details for a specific entity
8099
+ */
8100
+ async getReviewDetails(entityType, entityId, dateRange, filters) {
8101
+ var _a, _b, _c;
8102
+ const reviews = await this.fetchReviews(dateRange, filters);
8103
+ let relevantReviews = [];
8104
+ if (entityType === "practitioner") {
8105
+ relevantReviews = reviews.filter((r) => {
8106
+ var _a2;
8107
+ return ((_a2 = r.practitionerReview) == null ? void 0 : _a2.practitionerId) === entityId;
8108
+ });
8109
+ } else if (entityType === "procedure") {
8110
+ relevantReviews = reviews.filter((r) => {
8111
+ var _a2;
8112
+ return ((_a2 = r.procedureReview) == null ? void 0 : _a2.procedureId) === entityId;
8113
+ });
8114
+ }
8115
+ const details = [];
8116
+ for (const review of relevantReviews) {
8117
+ try {
8118
+ const appointmentDocRef = doc2(this.db, APPOINTMENTS_COLLECTION, review.appointmentId);
8119
+ const appointmentDoc = await getDoc2(appointmentDocRef);
8120
+ let appointment = null;
8121
+ if (appointmentDoc.exists()) {
8122
+ appointment = appointmentDoc.data();
8123
+ }
8124
+ const createdAt = review.createdAt instanceof Timestamp2 ? review.createdAt.toDate() : new Date(review.createdAt);
8125
+ const appointmentDate = (appointment == null ? void 0 : appointment.appointmentStartTime) ? appointment.appointmentStartTime instanceof Timestamp2 ? appointment.appointmentStartTime.toDate() : appointment.appointmentStartTime : createdAt;
8126
+ details.push({
8127
+ reviewId: review.id,
8128
+ appointmentId: review.appointmentId,
8129
+ patientId: review.patientId,
8130
+ patientName: review.patientName || ((_a = appointment == null ? void 0 : appointment.patientInfo) == null ? void 0 : _a.fullName),
8131
+ createdAt,
8132
+ practitionerReview: review.practitionerReview,
8133
+ procedureReview: review.procedureReview,
8134
+ procedureName: (_b = appointment == null ? void 0 : appointment.procedureInfo) == null ? void 0 : _b.name,
8135
+ practitionerName: (_c = appointment == null ? void 0 : appointment.practitionerInfo) == null ? void 0 : _c.name,
8136
+ appointmentDate
8137
+ });
8138
+ } catch (error) {
8139
+ console.warn(`Failed to enhance review ${review.id}:`, error);
8140
+ }
8141
+ }
8142
+ return details;
8143
+ }
8144
+ /**
8145
+ * Helper method to calculate average
8146
+ */
8147
+ calculateAverage(values) {
8148
+ if (values.length === 0) return 0;
8149
+ const sum = values.reduce((acc, val) => acc + val, 0);
8150
+ return sum / values.length;
8151
+ }
8152
+ /**
8153
+ * Calculate review trends over time
8154
+ * Groups reviews by period and calculates rating and recommendation metrics
8155
+ *
8156
+ * @param dateRange - Date range for trend analysis (must align with period boundaries)
8157
+ * @param period - Period type (week, month, quarter, year)
8158
+ * @param filters - Optional filters for clinic, practitioner, procedure
8159
+ * @param entityType - Optional entity type to group trends by (practitioner, procedure, technology)
8160
+ * @returns Array of review trends with percentage changes
8161
+ */
8162
+ async getReviewTrends(dateRange, period, filters, entityType) {
8163
+ const reviews = await this.fetchReviews(dateRange, filters);
8164
+ if (reviews.length === 0) {
8165
+ return [];
8166
+ }
8167
+ if (entityType) {
8168
+ return this.getGroupedReviewTrends(reviews, dateRange, period, entityType, filters);
8169
+ }
8170
+ const periods = generatePeriods(dateRange.start, dateRange.end, period);
8171
+ const trends = [];
8172
+ let previousAvgRating = 0;
8173
+ let previousRecRate = 0;
8174
+ periods.forEach((periodInfo) => {
8175
+ const periodReviews = reviews.filter((review) => {
8176
+ const reviewDate = review.createdAt instanceof Date ? review.createdAt : review.createdAt.toDate();
8177
+ return reviewDate >= periodInfo.startDate && reviewDate <= periodInfo.endDate;
8178
+ });
8179
+ if (periodReviews.length === 0) {
8180
+ trends.push({
8181
+ period: periodInfo.period,
8182
+ startDate: periodInfo.startDate,
8183
+ endDate: periodInfo.endDate,
8184
+ averageRating: 0,
8185
+ recommendationRate: 0,
8186
+ totalReviews: 0,
8187
+ previousPeriod: void 0
8188
+ });
8189
+ previousAvgRating = 0;
8190
+ previousRecRate = 0;
8191
+ return;
8192
+ }
8193
+ let totalRatingSum = 0;
8194
+ let totalRatingCount = 0;
8195
+ let totalRecommendations = 0;
8196
+ let totalRecommendationCount = 0;
8197
+ periodReviews.forEach((review) => {
8198
+ if (review.practitionerReview) {
8199
+ totalRatingSum += review.practitionerReview.overallRating;
8200
+ totalRatingCount++;
8201
+ if (review.practitionerReview.wouldRecommend) {
8202
+ totalRecommendations++;
8203
+ }
8204
+ totalRecommendationCount++;
8205
+ }
8206
+ if (review.procedureReview) {
8207
+ totalRatingSum += review.procedureReview.overallRating;
8208
+ totalRatingCount++;
8209
+ if (review.procedureReview.wouldRecommend) {
8210
+ totalRecommendations++;
8211
+ }
8212
+ totalRecommendationCount++;
8213
+ }
8214
+ if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
8215
+ review.extendedProcedureReviews.forEach((extReview) => {
8216
+ totalRatingSum += extReview.overallRating;
8217
+ totalRatingCount++;
8218
+ if (extReview.wouldRecommend) {
8219
+ totalRecommendations++;
8220
+ }
8221
+ totalRecommendationCount++;
8222
+ });
8223
+ }
8224
+ });
8225
+ const currentAvgRating = totalRatingCount > 0 ? totalRatingSum / totalRatingCount : 0;
8226
+ const currentRecRate = totalRecommendationCount > 0 ? totalRecommendations / totalRecommendationCount * 100 : 0;
8227
+ const trendChange = getTrendChange(currentAvgRating, previousAvgRating);
8228
+ trends.push({
8229
+ period: periodInfo.period,
8230
+ startDate: periodInfo.startDate,
8231
+ endDate: periodInfo.endDate,
8232
+ averageRating: currentAvgRating,
8233
+ recommendationRate: currentRecRate,
8234
+ totalReviews: periodReviews.length,
8235
+ previousPeriod: previousAvgRating > 0 ? {
8236
+ averageRating: previousAvgRating,
8237
+ recommendationRate: previousRecRate,
8238
+ percentageChange: Math.abs(trendChange.percentageChange),
8239
+ direction: trendChange.direction
8240
+ } : void 0
8241
+ });
8242
+ previousAvgRating = currentAvgRating;
8243
+ previousRecRate = currentRecRate;
8244
+ });
8245
+ return trends;
8246
+ }
8247
+ /**
8248
+ * Calculate grouped review trends (by practitioner, procedure, or technology)
8249
+ * Returns the AVERAGE across all entities of that type for each period
8250
+ * @private
8251
+ */
8252
+ async getGroupedReviewTrends(reviews, dateRange, period, entityType, filters) {
8253
+ const periods = generatePeriods(dateRange.start, dateRange.end, period);
8254
+ const trends = [];
8255
+ let appointments = [];
8256
+ let procedureToTechnologyMap = /* @__PURE__ */ new Map();
8257
+ if (entityType === "technology" && this.appointmentService) {
8258
+ const searchParams = { ...filters };
8259
+ if (dateRange) {
8260
+ searchParams.startDate = dateRange.start;
8261
+ searchParams.endDate = dateRange.end;
8262
+ }
8263
+ const appointmentsResult = await this.appointmentService.searchAppointments(searchParams);
8264
+ appointments = appointmentsResult.appointments || [];
8265
+ appointments.forEach((appointment) => {
8266
+ var _a, _b;
8267
+ if (appointment.procedureId && ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId)) {
8268
+ procedureToTechnologyMap.set(appointment.procedureId, {
8269
+ id: appointment.procedureExtendedInfo.procedureTechnologyId,
8270
+ name: appointment.procedureExtendedInfo.procedureTechnologyName || "Unknown Technology"
8271
+ });
8272
+ }
8273
+ if ((_b = appointment.metadata) == null ? void 0 : _b.extendedProcedures) {
8274
+ appointment.metadata.extendedProcedures.forEach((extProc) => {
8275
+ if (extProc.procedureId && extProc.procedureTechnologyId) {
8276
+ procedureToTechnologyMap.set(extProc.procedureId, {
8277
+ id: extProc.procedureTechnologyId,
8278
+ name: extProc.procedureTechnologyName || "Unknown Technology"
8279
+ });
8280
+ }
8281
+ });
8282
+ }
8283
+ });
8284
+ }
8285
+ let previousAvgRating = 0;
8286
+ let previousRecRate = 0;
8287
+ periods.forEach((periodInfo) => {
8288
+ const periodReviews = reviews.filter((review) => {
8289
+ const reviewDate = review.createdAt instanceof Date ? review.createdAt : review.createdAt.toDate();
8290
+ return reviewDate >= periodInfo.startDate && reviewDate <= periodInfo.endDate;
8291
+ });
8292
+ if (periodReviews.length === 0) {
8293
+ trends.push({
8294
+ period: periodInfo.period,
8295
+ startDate: periodInfo.startDate,
8296
+ endDate: periodInfo.endDate,
8297
+ averageRating: 0,
8298
+ recommendationRate: 0,
8299
+ totalReviews: 0,
8300
+ previousPeriod: void 0
8301
+ });
8302
+ previousAvgRating = 0;
8303
+ previousRecRate = 0;
8304
+ return;
8305
+ }
8306
+ let totalRatingSum = 0;
8307
+ let totalRatingCount = 0;
8308
+ let totalRecommendations = 0;
8309
+ let totalRecommendationCount = 0;
8310
+ periodReviews.forEach((review) => {
8311
+ var _a;
8312
+ if (entityType === "practitioner" && review.practitionerReview) {
8313
+ totalRatingSum += review.practitionerReview.overallRating;
8314
+ totalRatingCount++;
8315
+ if (review.practitionerReview.wouldRecommend) {
8316
+ totalRecommendations++;
8317
+ }
8318
+ totalRecommendationCount++;
8319
+ } else if (entityType === "procedure" && review.procedureReview) {
8320
+ totalRatingSum += review.procedureReview.overallRating;
8321
+ totalRatingCount++;
8322
+ if (review.procedureReview.wouldRecommend) {
8323
+ totalRecommendations++;
8324
+ }
8325
+ totalRecommendationCount++;
8326
+ if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
8327
+ review.extendedProcedureReviews.forEach((extReview) => {
8328
+ totalRatingSum += extReview.overallRating;
8329
+ totalRatingCount++;
8330
+ if (extReview.wouldRecommend) {
8331
+ totalRecommendations++;
8332
+ }
8333
+ totalRecommendationCount++;
8334
+ });
8335
+ }
8336
+ } else if (entityType === "technology") {
8337
+ if ((_a = review.procedureReview) == null ? void 0 : _a.procedureId) {
8338
+ const tech = procedureToTechnologyMap.get(review.procedureReview.procedureId);
8339
+ if (tech) {
8340
+ totalRatingSum += review.procedureReview.overallRating;
8341
+ totalRatingCount++;
8342
+ if (review.procedureReview.wouldRecommend) {
8343
+ totalRecommendations++;
8344
+ }
8345
+ totalRecommendationCount++;
8346
+ }
8347
+ }
8348
+ if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
8349
+ review.extendedProcedureReviews.forEach((extReview) => {
8350
+ if (extReview.procedureId) {
8351
+ const tech = procedureToTechnologyMap.get(extReview.procedureId);
8352
+ if (tech) {
8353
+ totalRatingSum += extReview.overallRating;
8354
+ totalRatingCount++;
8355
+ if (extReview.wouldRecommend) {
8356
+ totalRecommendations++;
8357
+ }
8358
+ totalRecommendationCount++;
8359
+ }
8360
+ }
8361
+ });
8362
+ }
8363
+ }
8364
+ });
8365
+ const currentAvgRating = totalRatingCount > 0 ? totalRatingSum / totalRatingCount : 0;
8366
+ const currentRecRate = totalRecommendationCount > 0 ? totalRecommendations / totalRecommendationCount * 100 : 0;
8367
+ const trendChange = getTrendChange(currentAvgRating, previousAvgRating);
8368
+ trends.push({
8369
+ period: periodInfo.period,
8370
+ startDate: periodInfo.startDate,
8371
+ endDate: periodInfo.endDate,
8372
+ averageRating: currentAvgRating,
8373
+ recommendationRate: currentRecRate,
8374
+ totalReviews: totalRatingCount,
8375
+ // Count of reviews for this entity type
8376
+ previousPeriod: previousAvgRating > 0 ? {
8377
+ averageRating: previousAvgRating,
8378
+ recommendationRate: previousRecRate,
8379
+ percentageChange: Math.abs(trendChange.percentageChange),
8380
+ direction: trendChange.direction
8381
+ } : void 0
8382
+ });
8383
+ previousAvgRating = currentAvgRating;
8384
+ previousRecRate = currentRecRate;
8385
+ });
8386
+ return trends;
8387
+ }
8388
+ };
8389
+
8390
+ // src/services/analytics/analytics.service.ts
8391
+ var AnalyticsService = class extends BaseService {
8392
+ /**
8393
+ * Creates a new AnalyticsService instance.
8394
+ *
8395
+ * @param db Firestore instance
8396
+ * @param auth Firebase Auth instance
8397
+ * @param app Firebase App instance
8398
+ * @param appointmentService Appointment service instance for querying appointments
8399
+ */
8400
+ constructor(db, auth, app, appointmentService) {
8401
+ super(db, auth, app);
8402
+ this.appointmentService = appointmentService;
8403
+ this.reviewAnalyticsService = new ReviewAnalyticsService(db, auth, app, appointmentService);
8404
+ }
8405
+ /**
8406
+ * Fetches appointments with optional filters
8407
+ *
8408
+ * @param filters - Optional filters
8409
+ * @param dateRange - Optional date range
8410
+ * @returns Array of appointments
8411
+ */
8412
+ async fetchAppointments(filters, dateRange) {
8413
+ try {
8414
+ const constraints = [];
8415
+ if (filters == null ? void 0 : filters.clinicBranchId) {
8416
+ constraints.push(where2("clinicBranchId", "==", filters.clinicBranchId));
8417
+ }
8418
+ if (filters == null ? void 0 : filters.practitionerId) {
8419
+ constraints.push(where2("practitionerId", "==", filters.practitionerId));
8420
+ }
8421
+ if (filters == null ? void 0 : filters.procedureId) {
8422
+ constraints.push(where2("procedureId", "==", filters.procedureId));
8423
+ }
8424
+ if (filters == null ? void 0 : filters.patientId) {
8425
+ constraints.push(where2("patientId", "==", filters.patientId));
8426
+ }
8427
+ if (dateRange) {
8428
+ constraints.push(where2("appointmentStartTime", ">=", Timestamp3.fromDate(dateRange.start)));
8429
+ constraints.push(where2("appointmentStartTime", "<=", Timestamp3.fromDate(dateRange.end)));
8430
+ }
8431
+ const searchParams = {};
8432
+ if (filters == null ? void 0 : filters.clinicBranchId) searchParams.clinicBranchId = filters.clinicBranchId;
8433
+ if (filters == null ? void 0 : filters.practitionerId) searchParams.practitionerId = filters.practitionerId;
8434
+ if (filters == null ? void 0 : filters.procedureId) searchParams.procedureId = filters.procedureId;
8435
+ if (filters == null ? void 0 : filters.patientId) searchParams.patientId = filters.patientId;
8436
+ if (dateRange) {
8437
+ searchParams.startDate = dateRange.start;
8438
+ searchParams.endDate = dateRange.end;
8439
+ }
8440
+ const result = await this.appointmentService.searchAppointments(searchParams);
8441
+ return result.appointments;
8442
+ } catch (error) {
8443
+ console.error("[AnalyticsService] Error fetching appointments:", error);
8444
+ throw error;
8445
+ }
8446
+ }
8447
+ // ==========================================
8448
+ // Practitioner Analytics
8449
+ // ==========================================
8450
+ /**
8451
+ * Get practitioner performance metrics
8452
+ * First checks for stored analytics, then calculates if not available or stale
8453
+ *
8454
+ * @param practitionerId - ID of the practitioner
8455
+ * @param dateRange - Optional date range filter
8456
+ * @param options - Options for reading stored analytics
8457
+ * @returns Practitioner analytics object
8458
+ */
8459
+ async getPractitionerAnalytics(practitionerId, dateRange, options) {
8460
+ var _a;
8461
+ if (dateRange && (options == null ? void 0 : options.useCache) !== false) {
8462
+ const period = this.determinePeriodFromDateRange(dateRange);
8463
+ const clinicBranchId = options == null ? void 0 : options.clinicBranchId;
8464
+ if (clinicBranchId) {
8465
+ const stored = await readStoredPractitionerAnalytics(
8466
+ this.db,
8467
+ clinicBranchId,
8468
+ practitionerId,
8469
+ { ...options, period }
8470
+ );
8471
+ if (stored) {
8472
+ const { metadata, ...analytics } = stored;
8473
+ return analytics;
8474
+ }
8475
+ }
8476
+ }
8477
+ const appointments = await this.fetchAppointments({ practitionerId }, dateRange);
8478
+ const completed = getCompletedAppointments(appointments);
8479
+ const canceled = getCanceledAppointments(appointments);
8480
+ const noShow = getNoShowAppointments(appointments);
8481
+ const pending = filterAppointments(appointments, { practitionerId }).filter(
8482
+ (a) => a.status === "pending" /* PENDING */
8483
+ );
8484
+ const confirmed = filterAppointments(appointments, { practitionerId }).filter(
8485
+ (a) => a.status === "confirmed" /* CONFIRMED */
8486
+ );
8487
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
8488
+ const timeMetrics = calculateAverageTimeMetrics(completed);
8489
+ const uniquePatients = new Set(appointments.map((a) => a.patientId));
8490
+ const returningPatients = new Set(
8491
+ appointments.filter((a) => {
8492
+ const patientAppointments = appointments.filter((ap) => ap.patientId === a.patientId);
8493
+ return patientAppointments.length > 1;
8494
+ }).map((a) => a.patientId)
8495
+ );
8496
+ const procedureMap = /* @__PURE__ */ new Map();
8497
+ completed.forEach((appointment) => {
8498
+ var _a2;
8499
+ const procId = appointment.procedureId;
8500
+ const procName = ((_a2 = appointment.procedureInfo) == null ? void 0 : _a2.name) || "Unknown";
8501
+ const cost = calculateAppointmentCost(appointment).cost;
8502
+ if (procedureMap.has(procId)) {
8503
+ const existing = procedureMap.get(procId);
8504
+ existing.count++;
8505
+ existing.revenue += cost;
8506
+ } else {
8507
+ procedureMap.set(procId, { name: procName, count: 1, revenue: cost });
8508
+ }
8509
+ });
8510
+ const topProcedures = Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
8511
+ procedureId,
8512
+ procedureName: data.name,
8513
+ count: data.count,
8514
+ revenue: data.revenue
8515
+ })).sort((a, b) => b.count - a.count).slice(0, 10);
8516
+ const practitionerName = appointments.length > 0 ? ((_a = appointments[0].practitionerInfo) == null ? void 0 : _a.name) || "Unknown" : "Unknown";
8517
+ return {
8518
+ total: appointments.length,
8519
+ dateRange,
8520
+ practitionerId,
8521
+ practitionerName,
8522
+ totalAppointments: appointments.length,
8523
+ completedAppointments: completed.length,
8524
+ canceledAppointments: canceled.length,
8525
+ noShowAppointments: noShow.length,
8526
+ pendingAppointments: pending.length,
8527
+ confirmedAppointments: confirmed.length,
8528
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
8529
+ noShowRate: calculatePercentage(noShow.length, appointments.length),
8530
+ averageBookedTime: timeMetrics.averageBookedDuration,
8531
+ averageActualTime: timeMetrics.averageActualDuration,
8532
+ timeEfficiency: timeMetrics.averageEfficiency,
8533
+ totalRevenue,
8534
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
8535
+ currency,
8536
+ topProcedures,
8537
+ patientRetentionRate: calculatePercentage(returningPatients.size, uniquePatients.size),
8538
+ uniquePatients: uniquePatients.size
8539
+ };
8540
+ }
8541
+ // ==========================================
8542
+ // Procedure Analytics
8543
+ // ==========================================
8544
+ /**
8545
+ * Get procedure performance metrics
8546
+ * First checks for stored analytics, then calculates if not available or stale
8547
+ *
8548
+ * @param procedureId - ID of the procedure (optional, if not provided returns all)
8549
+ * @param dateRange - Optional date range filter
8550
+ * @param options - Options for reading stored analytics
8551
+ * @returns Procedure analytics object or array
8552
+ */
8553
+ async getProcedureAnalytics(procedureId, dateRange, options) {
8554
+ if (procedureId && dateRange && (options == null ? void 0 : options.useCache) !== false) {
8555
+ const period = this.determinePeriodFromDateRange(dateRange);
8556
+ const clinicBranchId = options == null ? void 0 : options.clinicBranchId;
8557
+ if (clinicBranchId) {
8558
+ const stored = await readStoredProcedureAnalytics(
8559
+ this.db,
8560
+ clinicBranchId,
8561
+ procedureId,
8562
+ { ...options, period }
8563
+ );
8564
+ if (stored) {
8565
+ const { metadata, ...analytics } = stored;
8566
+ return analytics;
8567
+ }
8568
+ }
8569
+ }
8570
+ const appointments = await this.fetchAppointments(procedureId ? { procedureId } : void 0, dateRange);
8571
+ if (procedureId) {
8572
+ return this.calculateProcedureAnalytics(appointments, procedureId);
8573
+ }
8574
+ const procedureMap = /* @__PURE__ */ new Map();
8575
+ appointments.forEach((appointment) => {
8576
+ const procId = appointment.procedureId;
8577
+ if (!procedureMap.has(procId)) {
8578
+ procedureMap.set(procId, []);
8579
+ }
8580
+ procedureMap.get(procId).push(appointment);
8581
+ });
8582
+ return Array.from(procedureMap.entries()).map(
8583
+ ([procId, procAppointments]) => this.calculateProcedureAnalytics(procAppointments, procId)
8584
+ );
8585
+ }
8586
+ /**
8587
+ * Calculate analytics for a specific procedure
8588
+ *
8589
+ * @param appointments - Appointments for the procedure
8590
+ * @param procedureId - Procedure ID
8591
+ * @returns Procedure analytics
8592
+ */
8593
+ calculateProcedureAnalytics(appointments, procedureId) {
8594
+ const completed = getCompletedAppointments(appointments);
8595
+ const canceled = getCanceledAppointments(appointments);
8596
+ const noShow = getNoShowAppointments(appointments);
8597
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
8598
+ const timeMetrics = calculateAverageTimeMetrics(completed);
8599
+ const firstAppointment = appointments[0];
8600
+ const procedureInfo = (firstAppointment == null ? void 0 : firstAppointment.procedureExtendedInfo) || (firstAppointment == null ? void 0 : firstAppointment.procedureInfo);
8601
+ const productMap = /* @__PURE__ */ new Map();
8602
+ completed.forEach((appointment) => {
8603
+ const products = extractProductUsage(appointment);
8604
+ products.forEach((product) => {
8605
+ if (productMap.has(product.productId)) {
8606
+ const existing = productMap.get(product.productId);
8607
+ existing.quantity += product.quantity;
8608
+ existing.revenue += product.subtotal;
8609
+ existing.usageCount++;
8610
+ } else {
8611
+ productMap.set(product.productId, {
8612
+ name: product.productName,
8613
+ brandName: product.brandName,
8614
+ quantity: product.quantity,
8615
+ revenue: product.subtotal,
8616
+ usageCount: 1
8617
+ });
8618
+ }
8619
+ });
8620
+ });
8621
+ const productUsage = Array.from(productMap.entries()).map(([productId, data]) => ({
8622
+ productId,
8623
+ productName: data.name,
8624
+ brandName: data.brandName,
8625
+ totalQuantity: data.quantity,
8626
+ totalRevenue: data.revenue,
8627
+ usageCount: data.usageCount
8628
+ }));
8629
+ return {
8630
+ total: appointments.length,
8631
+ procedureId,
8632
+ procedureName: (procedureInfo == null ? void 0 : procedureInfo.name) || "Unknown",
8633
+ procedureFamily: (procedureInfo == null ? void 0 : procedureInfo.procedureFamily) || "",
8634
+ categoryName: (procedureInfo == null ? void 0 : procedureInfo.procedureCategoryName) || "",
8635
+ subcategoryName: (procedureInfo == null ? void 0 : procedureInfo.procedureSubCategoryName) || "",
8636
+ technologyName: (procedureInfo == null ? void 0 : procedureInfo.procedureTechnologyName) || "",
8637
+ totalAppointments: appointments.length,
8638
+ completedAppointments: completed.length,
8639
+ canceledAppointments: canceled.length,
8640
+ noShowAppointments: noShow.length,
8641
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
8642
+ noShowRate: calculatePercentage(noShow.length, appointments.length),
8643
+ averageCost: completed.length > 0 ? totalRevenue / completed.length : 0,
8644
+ totalRevenue,
8645
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
8646
+ currency,
8647
+ averageBookedDuration: timeMetrics.averageBookedDuration,
8648
+ averageActualDuration: timeMetrics.averageActualDuration,
8649
+ productUsage
8650
+ };
8651
+ }
8652
+ /**
8653
+ * Get procedure popularity metrics
8654
+ *
8655
+ * @param dateRange - Optional date range filter
8656
+ * @param limit - Number of top procedures to return
8657
+ * @returns Array of procedure popularity metrics
8658
+ */
8659
+ async getProcedurePopularity(dateRange, limit = 10) {
8660
+ const appointments = await this.fetchAppointments(void 0, dateRange);
8661
+ const completed = getCompletedAppointments(appointments);
8662
+ const procedureMap = /* @__PURE__ */ new Map();
8663
+ completed.forEach((appointment) => {
8664
+ const procId = appointment.procedureId;
8665
+ const procInfo = appointment.procedureExtendedInfo || appointment.procedureInfo;
8666
+ if (procedureMap.has(procId)) {
8667
+ procedureMap.get(procId).count++;
8668
+ } else {
8669
+ procedureMap.set(procId, {
8670
+ name: (procInfo == null ? void 0 : procInfo.name) || "Unknown",
8671
+ category: (procInfo == null ? void 0 : procInfo.procedureCategoryName) || "",
8672
+ subcategory: (procInfo == null ? void 0 : procInfo.procedureSubCategoryName) || "",
8673
+ technology: (procInfo == null ? void 0 : procInfo.procedureTechnologyName) || "",
8674
+ count: 1
8675
+ });
8676
+ }
8677
+ });
8678
+ return Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
8679
+ procedureId,
8680
+ procedureName: data.name,
8681
+ categoryName: data.category,
8682
+ subcategoryName: data.subcategory,
8683
+ technologyName: data.technology,
8684
+ appointmentCount: data.count,
8685
+ completedCount: data.count,
8686
+ rank: 0
8687
+ // Will be set after sorting
8688
+ })).sort((a, b) => b.appointmentCount - a.appointmentCount).slice(0, limit).map((item, index) => ({ ...item, rank: index + 1 }));
8689
+ }
8690
+ /**
8691
+ * Get procedure profitability metrics
8692
+ *
8693
+ * @param dateRange - Optional date range filter
8694
+ * @param limit - Number of top procedures to return
8695
+ * @returns Array of procedure profitability metrics
8696
+ */
8697
+ async getProcedureProfitability(dateRange, limit = 10) {
8698
+ const appointments = await this.fetchAppointments(void 0, dateRange);
8699
+ const completed = getCompletedAppointments(appointments);
8700
+ const procedureMap = /* @__PURE__ */ new Map();
8701
+ completed.forEach((appointment) => {
8702
+ const procId = appointment.procedureId;
8703
+ const procInfo = appointment.procedureExtendedInfo || appointment.procedureInfo;
8704
+ const cost = calculateAppointmentCost(appointment).cost;
8705
+ if (procedureMap.has(procId)) {
8706
+ const existing = procedureMap.get(procId);
8707
+ existing.revenue += cost;
8708
+ existing.count++;
8709
+ } else {
8710
+ procedureMap.set(procId, {
8711
+ name: (procInfo == null ? void 0 : procInfo.name) || "Unknown",
8712
+ category: (procInfo == null ? void 0 : procInfo.procedureCategoryName) || "",
8713
+ subcategory: (procInfo == null ? void 0 : procInfo.procedureSubCategoryName) || "",
8714
+ technology: (procInfo == null ? void 0 : procInfo.procedureTechnologyName) || "",
8715
+ revenue: cost,
8716
+ count: 1
8717
+ });
8718
+ }
8719
+ });
8720
+ return Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
8721
+ procedureId,
8722
+ procedureName: data.name,
8723
+ categoryName: data.category,
8724
+ subcategoryName: data.subcategory,
8725
+ technologyName: data.technology,
8726
+ totalRevenue: data.revenue,
8727
+ averageRevenue: data.count > 0 ? data.revenue / data.count : 0,
8728
+ appointmentCount: data.count,
8729
+ rank: 0
8730
+ // Will be set after sorting
8731
+ })).sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, limit).map((item, index) => ({ ...item, rank: index + 1 }));
8732
+ }
8733
+ // ==========================================
8734
+ // Time Efficiency Analytics
8735
+ // ==========================================
8736
+ /**
8737
+ * Get time efficiency metrics grouped by clinic, practitioner, procedure, patient, or technology
8738
+ *
8739
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient' | 'technology'
8740
+ * @param dateRange - Optional date range filter
8741
+ * @param filters - Optional additional filters
8742
+ * @returns Grouped time efficiency metrics
8743
+ */
8744
+ async getTimeEfficiencyMetricsByEntity(groupBy, dateRange, filters) {
8745
+ const appointments = await this.fetchAppointments(filters, dateRange);
8746
+ return calculateGroupedTimeEfficiencyMetrics(appointments, groupBy);
8747
+ }
8748
+ /**
8749
+ * Get time efficiency metrics for appointments
8750
+ * First checks for stored analytics, then calculates if not available or stale
8751
+ *
8752
+ * @param filters - Optional filters
8753
+ * @param dateRange - Optional date range filter
8754
+ * @param options - Options for reading stored analytics
8755
+ * @returns Time efficiency metrics
8756
+ */
8757
+ async getTimeEfficiencyMetrics(filters, dateRange, options) {
8758
+ if ((filters == null ? void 0 : filters.clinicBranchId) && dateRange && (options == null ? void 0 : options.useCache) !== false) {
8759
+ const period = this.determinePeriodFromDateRange(dateRange);
8760
+ const stored = await readStoredTimeEfficiencyMetrics(
8761
+ this.db,
8762
+ filters.clinicBranchId,
8763
+ { ...options, period }
8764
+ );
8765
+ if (stored) {
8766
+ const { metadata, ...metrics } = stored;
8767
+ return metrics;
8768
+ }
8769
+ }
8770
+ const appointments = await this.fetchAppointments(filters, dateRange);
8771
+ const completed = getCompletedAppointments(appointments);
8772
+ const timeMetrics = calculateAverageTimeMetrics(completed);
8773
+ const efficiencyDistribution = calculateEfficiencyDistribution(completed);
8774
+ return {
8775
+ totalAppointments: completed.length,
8776
+ appointmentsWithActualTime: timeMetrics.appointmentsWithActualTime,
8777
+ averageBookedDuration: timeMetrics.averageBookedDuration,
8778
+ averageActualDuration: timeMetrics.averageActualDuration,
8779
+ averageEfficiency: timeMetrics.averageEfficiency,
8780
+ totalOverrun: timeMetrics.totalOverrun,
8781
+ totalUnderutilization: timeMetrics.totalUnderutilization,
8782
+ averageOverrun: timeMetrics.averageOverrun,
8783
+ averageUnderutilization: timeMetrics.averageUnderutilization,
8784
+ efficiencyDistribution
8785
+ };
8786
+ }
8787
+ // ==========================================
8788
+ // Cancellation & No-Show Analytics
8789
+ // ==========================================
8790
+ /**
8791
+ * Get cancellation metrics
8792
+ * First checks for stored analytics when grouping by clinic, then calculates if not available or stale
8793
+ *
8794
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'patient' | 'procedure' | 'technology'
8795
+ * @param dateRange - Optional date range filter
8796
+ * @param options - Options for reading stored analytics (requires clinicBranchId for cache)
8797
+ * @returns Cancellation metrics grouped by specified entity
8798
+ */
8799
+ async getCancellationMetrics(groupBy, dateRange, options) {
8800
+ if (groupBy === "clinic" && dateRange && (options == null ? void 0 : options.useCache) !== false && (options == null ? void 0 : options.clinicBranchId)) {
8801
+ const period = this.determinePeriodFromDateRange(dateRange);
8802
+ const stored = await readStoredCancellationMetrics(
8803
+ this.db,
8804
+ options.clinicBranchId,
8805
+ "clinic",
8806
+ { ...options, period }
8807
+ );
8808
+ if (stored) {
8809
+ const { metadata, ...metrics } = stored;
8810
+ return metrics;
8811
+ }
8812
+ }
8813
+ const appointments = await this.fetchAppointments(void 0, dateRange);
8814
+ const canceled = getCanceledAppointments(appointments);
8815
+ if (groupBy === "clinic") {
8816
+ return this.groupCancellationsByClinic(canceled, appointments);
8817
+ } else if (groupBy === "practitioner") {
8818
+ return this.groupCancellationsByPractitioner(canceled, appointments);
8819
+ } else if (groupBy === "patient") {
8820
+ return this.groupCancellationsByPatient(canceled, appointments);
8821
+ } else if (groupBy === "technology") {
8822
+ return this.groupCancellationsByTechnology(canceled, appointments);
8823
+ } else {
8824
+ return this.groupCancellationsByProcedure(canceled, appointments);
8825
+ }
8826
+ }
8827
+ /**
8828
+ * Group cancellations by clinic
8829
+ */
8830
+ groupCancellationsByClinic(canceled, allAppointments) {
8831
+ const clinicMap = /* @__PURE__ */ new Map();
8832
+ allAppointments.forEach((appointment) => {
8833
+ var _a;
8834
+ const clinicId = appointment.clinicBranchId;
8835
+ const clinicName = ((_a = appointment.clinicInfo) == null ? void 0 : _a.name) || "Unknown";
8836
+ if (!clinicMap.has(clinicId)) {
8837
+ clinicMap.set(clinicId, { name: clinicName, canceled: [], all: [] });
8838
+ }
8839
+ clinicMap.get(clinicId).all.push(appointment);
8840
+ });
8841
+ canceled.forEach((appointment) => {
8842
+ const clinicId = appointment.clinicBranchId;
8843
+ if (clinicMap.has(clinicId)) {
8844
+ clinicMap.get(clinicId).canceled.push(appointment);
8845
+ }
8846
+ });
8847
+ return Array.from(clinicMap.entries()).map(
8848
+ ([clinicId, data]) => this.calculateCancellationMetrics(clinicId, data.name, "clinic", data.canceled, data.all)
8849
+ );
8850
+ }
8851
+ /**
8852
+ * Group cancellations by practitioner
8853
+ */
8854
+ groupCancellationsByPractitioner(canceled, allAppointments) {
8855
+ const practitionerMap = /* @__PURE__ */ new Map();
8856
+ allAppointments.forEach((appointment) => {
8857
+ var _a;
8858
+ const practitionerId = appointment.practitionerId;
8859
+ const practitionerName = ((_a = appointment.practitionerInfo) == null ? void 0 : _a.name) || "Unknown";
8860
+ if (!practitionerMap.has(practitionerId)) {
8861
+ practitionerMap.set(practitionerId, { name: practitionerName, canceled: [], all: [] });
8862
+ }
8863
+ practitionerMap.get(practitionerId).all.push(appointment);
8864
+ });
8865
+ canceled.forEach((appointment) => {
8866
+ const practitionerId = appointment.practitionerId;
8867
+ if (practitionerMap.has(practitionerId)) {
8868
+ practitionerMap.get(practitionerId).canceled.push(appointment);
8869
+ }
8870
+ });
8871
+ return Array.from(practitionerMap.entries()).map(
8872
+ ([practitionerId, data]) => this.calculateCancellationMetrics(
8873
+ practitionerId,
8874
+ data.name,
8875
+ "practitioner",
8876
+ data.canceled,
8877
+ data.all
8878
+ )
8879
+ );
8880
+ }
8881
+ /**
8882
+ * Group cancellations by patient
8883
+ */
8884
+ groupCancellationsByPatient(canceled, allAppointments) {
8885
+ const patientMap = /* @__PURE__ */ new Map();
8886
+ allAppointments.forEach((appointment) => {
8887
+ var _a;
8888
+ const patientId = appointment.patientId;
8889
+ const patientName = ((_a = appointment.patientInfo) == null ? void 0 : _a.fullName) || "Unknown";
8890
+ if (!patientMap.has(patientId)) {
8891
+ patientMap.set(patientId, { name: patientName, canceled: [], all: [] });
8892
+ }
8893
+ patientMap.get(patientId).all.push(appointment);
8894
+ });
8895
+ canceled.forEach((appointment) => {
8896
+ const patientId = appointment.patientId;
8897
+ if (patientMap.has(patientId)) {
8898
+ patientMap.get(patientId).canceled.push(appointment);
8899
+ }
8900
+ });
8901
+ return Array.from(patientMap.entries()).map(
8902
+ ([patientId, data]) => this.calculateCancellationMetrics(patientId, data.name, "patient", data.canceled, data.all)
8903
+ );
8904
+ }
8905
+ /**
8906
+ * Group cancellations by procedure
8907
+ */
8908
+ groupCancellationsByProcedure(canceled, allAppointments) {
8909
+ const procedureMap = /* @__PURE__ */ new Map();
8910
+ allAppointments.forEach((appointment) => {
8911
+ var _a, _b;
8912
+ const procedureId = appointment.procedureId;
8913
+ const procedureName = ((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown";
8914
+ if (!procedureMap.has(procedureId)) {
8915
+ procedureMap.set(procedureId, {
8916
+ name: procedureName,
8917
+ canceled: [],
8918
+ all: [],
8919
+ practitionerId: appointment.practitionerId,
8920
+ practitionerName: (_b = appointment.practitionerInfo) == null ? void 0 : _b.name
8921
+ });
8922
+ }
8923
+ procedureMap.get(procedureId).all.push(appointment);
8924
+ });
8925
+ canceled.forEach((appointment) => {
8926
+ const procedureId = appointment.procedureId;
8927
+ if (procedureMap.has(procedureId)) {
8928
+ procedureMap.get(procedureId).canceled.push(appointment);
8929
+ }
8930
+ });
8931
+ return Array.from(procedureMap.entries()).map(([procedureId, data]) => {
8932
+ const metrics = this.calculateCancellationMetrics(
8933
+ procedureId,
8934
+ data.name,
8935
+ "procedure",
8936
+ data.canceled,
8937
+ data.all
8938
+ );
8939
+ return {
8940
+ ...metrics,
8941
+ ...data.practitionerId && { practitionerId: data.practitionerId },
8942
+ ...data.practitionerName && { practitionerName: data.practitionerName }
8943
+ };
8944
+ });
8945
+ }
8946
+ /**
8947
+ * Group cancellations by technology
8948
+ * Aggregates all procedures using the same technology across all doctors
8949
+ */
8950
+ groupCancellationsByTechnology(canceled, allAppointments) {
8951
+ const technologyMap = /* @__PURE__ */ new Map();
8952
+ allAppointments.forEach((appointment) => {
8953
+ var _a, _b, _c;
8954
+ const technologyId = ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
8955
+ const technologyName = ((_b = appointment.procedureExtendedInfo) == null ? void 0 : _b.procedureTechnologyName) || ((_c = appointment.procedureInfo) == null ? void 0 : _c.technologyName) || "Unknown";
8956
+ if (!technologyMap.has(technologyId)) {
8957
+ technologyMap.set(technologyId, { name: technologyName, canceled: [], all: [] });
8958
+ }
8959
+ technologyMap.get(technologyId).all.push(appointment);
8960
+ });
8961
+ canceled.forEach((appointment) => {
8962
+ var _a;
8963
+ const technologyId = ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
8964
+ if (technologyMap.has(technologyId)) {
8965
+ technologyMap.get(technologyId).canceled.push(appointment);
8966
+ }
8967
+ });
8968
+ return Array.from(technologyMap.entries()).map(
8969
+ ([technologyId, data]) => this.calculateCancellationMetrics(
8970
+ technologyId,
8971
+ data.name,
8972
+ "technology",
8973
+ data.canceled,
8974
+ data.all
8975
+ )
8976
+ );
8977
+ }
8978
+ /**
8979
+ * Calculate cancellation metrics for a specific entity
8980
+ */
8981
+ calculateCancellationMetrics(entityId, entityName, entityType, canceled, all) {
8982
+ const canceledByPatient = canceled.filter(
8983
+ (a) => a.status === "canceled_patient" /* CANCELED_PATIENT */
8984
+ ).length;
8985
+ const canceledByClinic = canceled.filter(
8986
+ (a) => a.status === "canceled_clinic" /* CANCELED_CLINIC */
8987
+ ).length;
8988
+ const canceledRescheduled = canceled.filter(
8989
+ (a) => a.status === "canceled_patient_rescheduled" /* CANCELED_PATIENT_RESCHEDULED */
8990
+ ).length;
8991
+ const leadTimes = canceled.map((a) => calculateCancellationLeadTime(a)).filter((lt) => lt !== null);
8992
+ const averageLeadTime = leadTimes.length > 0 ? leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length : 0;
8993
+ const reasonMap = /* @__PURE__ */ new Map();
8994
+ canceled.forEach((appointment) => {
8995
+ const reason = appointment.cancellationReason || "No reason provided";
8996
+ reasonMap.set(reason, (reasonMap.get(reason) || 0) + 1);
8997
+ });
8998
+ const cancellationReasons = Array.from(reasonMap.entries()).map(([reason, count]) => ({
8999
+ reason,
9000
+ count,
9001
+ percentage: calculatePercentage(count, canceled.length)
9002
+ }));
9003
+ return {
9004
+ entityId,
9005
+ entityName,
9006
+ entityType,
9007
+ totalAppointments: all.length,
9008
+ canceledAppointments: canceled.length,
9009
+ cancellationRate: calculatePercentage(canceled.length, all.length),
9010
+ canceledByPatient,
9011
+ canceledByClinic,
9012
+ canceledByPractitioner: 0,
9013
+ // Not tracked in current status enum
9014
+ canceledRescheduled,
9015
+ averageCancellationLeadTime: Math.round(averageLeadTime * 100) / 100,
9016
+ cancellationReasons
9017
+ };
9018
+ }
9019
+ /**
9020
+ * Get no-show metrics
9021
+ * First checks for stored analytics when grouping by clinic, then calculates if not available or stale
9022
+ *
9023
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'patient' | 'procedure' | 'technology'
9024
+ * @param dateRange - Optional date range filter
9025
+ * @param options - Options for reading stored analytics (requires clinicBranchId for cache)
9026
+ * @returns No-show metrics grouped by specified entity
9027
+ */
9028
+ async getNoShowMetrics(groupBy, dateRange, options) {
9029
+ if (groupBy === "clinic" && dateRange && (options == null ? void 0 : options.useCache) !== false && (options == null ? void 0 : options.clinicBranchId)) {
9030
+ const period = this.determinePeriodFromDateRange(dateRange);
9031
+ const stored = await readStoredNoShowMetrics(
9032
+ this.db,
9033
+ options.clinicBranchId,
9034
+ "clinic",
9035
+ { ...options, period }
9036
+ );
9037
+ if (stored) {
9038
+ const { metadata, ...metrics } = stored;
9039
+ return metrics;
9040
+ }
9041
+ }
9042
+ const appointments = await this.fetchAppointments(void 0, dateRange);
9043
+ const noShow = getNoShowAppointments(appointments);
9044
+ if (groupBy === "clinic") {
9045
+ return this.groupNoShowsByClinic(noShow, appointments);
9046
+ } else if (groupBy === "practitioner") {
9047
+ return this.groupNoShowsByPractitioner(noShow, appointments);
9048
+ } else if (groupBy === "patient") {
9049
+ return this.groupNoShowsByPatient(noShow, appointments);
9050
+ } else if (groupBy === "technology") {
9051
+ return this.groupNoShowsByTechnology(noShow, appointments);
9052
+ } else {
9053
+ return this.groupNoShowsByProcedure(noShow, appointments);
9054
+ }
9055
+ }
9056
+ /**
9057
+ * Group no-shows by clinic
9058
+ */
9059
+ groupNoShowsByClinic(noShow, allAppointments) {
9060
+ const clinicMap = /* @__PURE__ */ new Map();
9061
+ allAppointments.forEach((appointment) => {
9062
+ var _a;
9063
+ const clinicId = appointment.clinicBranchId;
9064
+ const clinicName = ((_a = appointment.clinicInfo) == null ? void 0 : _a.name) || "Unknown";
9065
+ if (!clinicMap.has(clinicId)) {
9066
+ clinicMap.set(clinicId, { name: clinicName, noShow: [], all: [] });
9067
+ }
9068
+ clinicMap.get(clinicId).all.push(appointment);
9069
+ });
9070
+ noShow.forEach((appointment) => {
9071
+ const clinicId = appointment.clinicBranchId;
9072
+ if (clinicMap.has(clinicId)) {
9073
+ clinicMap.get(clinicId).noShow.push(appointment);
9074
+ }
9075
+ });
9076
+ return Array.from(clinicMap.entries()).map(([clinicId, data]) => ({
9077
+ entityId: clinicId,
9078
+ entityName: data.name,
9079
+ entityType: "clinic",
9080
+ totalAppointments: data.all.length,
9081
+ noShowAppointments: data.noShow.length,
9082
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length)
9083
+ }));
9084
+ }
9085
+ /**
9086
+ * Group no-shows by practitioner
9087
+ */
9088
+ groupNoShowsByPractitioner(noShow, allAppointments) {
9089
+ const practitionerMap = /* @__PURE__ */ new Map();
9090
+ allAppointments.forEach((appointment) => {
9091
+ var _a;
9092
+ const practitionerId = appointment.practitionerId;
9093
+ const practitionerName = ((_a = appointment.practitionerInfo) == null ? void 0 : _a.name) || "Unknown";
9094
+ if (!practitionerMap.has(practitionerId)) {
9095
+ practitionerMap.set(practitionerId, { name: practitionerName, noShow: [], all: [] });
9096
+ }
9097
+ practitionerMap.get(practitionerId).all.push(appointment);
9098
+ });
9099
+ noShow.forEach((appointment) => {
9100
+ const practitionerId = appointment.practitionerId;
9101
+ if (practitionerMap.has(practitionerId)) {
9102
+ practitionerMap.get(practitionerId).noShow.push(appointment);
9103
+ }
9104
+ });
9105
+ return Array.from(practitionerMap.entries()).map(([practitionerId, data]) => ({
9106
+ entityId: practitionerId,
9107
+ entityName: data.name,
9108
+ entityType: "practitioner",
9109
+ totalAppointments: data.all.length,
9110
+ noShowAppointments: data.noShow.length,
9111
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length)
9112
+ }));
9113
+ }
9114
+ /**
9115
+ * Group no-shows by patient
9116
+ */
9117
+ groupNoShowsByPatient(noShow, allAppointments) {
9118
+ const patientMap = /* @__PURE__ */ new Map();
9119
+ allAppointments.forEach((appointment) => {
9120
+ var _a;
9121
+ const patientId = appointment.patientId;
9122
+ const patientName = ((_a = appointment.patientInfo) == null ? void 0 : _a.fullName) || "Unknown";
9123
+ if (!patientMap.has(patientId)) {
9124
+ patientMap.set(patientId, { name: patientName, noShow: [], all: [] });
9125
+ }
9126
+ patientMap.get(patientId).all.push(appointment);
9127
+ });
9128
+ noShow.forEach((appointment) => {
9129
+ const patientId = appointment.patientId;
9130
+ if (patientMap.has(patientId)) {
9131
+ patientMap.get(patientId).noShow.push(appointment);
9132
+ }
9133
+ });
9134
+ return Array.from(patientMap.entries()).map(([patientId, data]) => ({
9135
+ entityId: patientId,
9136
+ entityName: data.name,
9137
+ entityType: "patient",
9138
+ totalAppointments: data.all.length,
9139
+ noShowAppointments: data.noShow.length,
9140
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length)
9141
+ }));
9142
+ }
9143
+ /**
9144
+ * Group no-shows by procedure
9145
+ */
9146
+ groupNoShowsByProcedure(noShow, allAppointments) {
9147
+ const procedureMap = /* @__PURE__ */ new Map();
9148
+ allAppointments.forEach((appointment) => {
9149
+ var _a, _b;
9150
+ const procedureId = appointment.procedureId;
9151
+ const procedureName = ((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown";
9152
+ if (!procedureMap.has(procedureId)) {
9153
+ procedureMap.set(procedureId, {
9154
+ name: procedureName,
9155
+ noShow: [],
9156
+ all: [],
9157
+ practitionerId: appointment.practitionerId,
9158
+ practitionerName: (_b = appointment.practitionerInfo) == null ? void 0 : _b.name
9159
+ });
9160
+ }
9161
+ procedureMap.get(procedureId).all.push(appointment);
9162
+ });
9163
+ noShow.forEach((appointment) => {
9164
+ const procedureId = appointment.procedureId;
9165
+ if (procedureMap.has(procedureId)) {
9166
+ procedureMap.get(procedureId).noShow.push(appointment);
9167
+ }
9168
+ });
9169
+ return Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
9170
+ entityId: procedureId,
9171
+ entityName: data.name,
9172
+ entityType: "procedure",
9173
+ totalAppointments: data.all.length,
9174
+ noShowAppointments: data.noShow.length,
9175
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length),
9176
+ ...data.practitionerId && { practitionerId: data.practitionerId },
9177
+ ...data.practitionerName && { practitionerName: data.practitionerName }
9178
+ }));
9179
+ }
9180
+ /**
9181
+ * Group no-shows by technology
9182
+ * Aggregates all procedures using the same technology across all doctors
9183
+ */
9184
+ groupNoShowsByTechnology(noShow, allAppointments) {
9185
+ const technologyMap = /* @__PURE__ */ new Map();
9186
+ allAppointments.forEach((appointment) => {
9187
+ var _a, _b, _c;
9188
+ const technologyId = ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
9189
+ const technologyName = ((_b = appointment.procedureExtendedInfo) == null ? void 0 : _b.procedureTechnologyName) || ((_c = appointment.procedureInfo) == null ? void 0 : _c.technologyName) || "Unknown";
9190
+ if (!technologyMap.has(technologyId)) {
9191
+ technologyMap.set(technologyId, { name: technologyName, noShow: [], all: [] });
9192
+ }
9193
+ technologyMap.get(technologyId).all.push(appointment);
9194
+ });
9195
+ noShow.forEach((appointment) => {
9196
+ var _a;
9197
+ const technologyId = ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
9198
+ if (technologyMap.has(technologyId)) {
9199
+ technologyMap.get(technologyId).noShow.push(appointment);
9200
+ }
9201
+ });
9202
+ return Array.from(technologyMap.entries()).map(([technologyId, data]) => ({
9203
+ entityId: technologyId,
9204
+ entityName: data.name,
9205
+ entityType: "technology",
9206
+ totalAppointments: data.all.length,
9207
+ noShowAppointments: data.noShow.length,
9208
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length)
9209
+ }));
9210
+ }
9211
+ // ==========================================
9212
+ // Financial Analytics
9213
+ // ==========================================
9214
+ /**
9215
+ * Get revenue metrics grouped by clinic, practitioner, procedure, patient, or technology
9216
+ *
9217
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient' | 'technology'
9218
+ * @param dateRange - Optional date range filter
9219
+ * @param filters - Optional additional filters
9220
+ * @returns Grouped revenue metrics
9221
+ */
9222
+ async getRevenueMetricsByEntity(groupBy, dateRange, filters) {
9223
+ const appointments = await this.fetchAppointments(filters, dateRange);
9224
+ return calculateGroupedRevenueMetrics(appointments, groupBy);
9225
+ }
9226
+ /**
9227
+ * Get revenue metrics
9228
+ * First checks for stored analytics, then calculates if not available or stale
9229
+ *
9230
+ * IMPORTANT: Financial calculations only consider COMPLETED appointments.
9231
+ * Confirmed, pending, canceled, and no-show appointments are NOT included in revenue calculations.
9232
+ * Only procedures that have been completed generate revenue.
9233
+ *
9234
+ * @param filters - Optional filters
9235
+ * @param dateRange - Optional date range filter
9236
+ * @param options - Options for reading stored analytics
9237
+ * @returns Revenue metrics
9238
+ */
9239
+ async getRevenueMetrics(filters, dateRange, options) {
9240
+ if ((filters == null ? void 0 : filters.clinicBranchId) && dateRange && (options == null ? void 0 : options.useCache) !== false) {
9241
+ const period = this.determinePeriodFromDateRange(dateRange);
9242
+ const stored = await readStoredRevenueMetrics(
9243
+ this.db,
9244
+ filters.clinicBranchId,
9245
+ { ...options, period }
9246
+ );
9247
+ if (stored) {
9248
+ const { metadata, ...metrics } = stored;
9249
+ return metrics;
9250
+ }
9251
+ }
9252
+ const appointments = await this.fetchAppointments(filters, dateRange);
9253
+ const completed = getCompletedAppointments(appointments);
9254
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
9255
+ const revenueByStatus = {};
9256
+ const { totalRevenue: completedRevenue } = calculateTotalRevenue(completed);
9257
+ revenueByStatus["completed" /* COMPLETED */] = completedRevenue;
9258
+ const revenueByPaymentStatus = {};
9259
+ Object.values(PaymentStatus).forEach((paymentStatus) => {
9260
+ const paymentAppointments = completed.filter((a) => a.paymentStatus === paymentStatus);
9261
+ const { totalRevenue: paymentRevenue } = calculateTotalRevenue(paymentAppointments);
9262
+ revenueByPaymentStatus[paymentStatus] = paymentRevenue;
9263
+ });
9264
+ const unpaid = completed.filter((a) => a.paymentStatus === "unpaid" /* UNPAID */);
9265
+ const refunded = completed.filter((a) => a.paymentStatus === "refunded" /* REFUNDED */);
9266
+ const { totalRevenue: unpaidRevenue } = calculateTotalRevenue(unpaid);
9267
+ const { totalRevenue: refundedRevenue } = calculateTotalRevenue(refunded);
9268
+ let totalTax = 0;
9269
+ let totalSubtotal = 0;
9270
+ completed.forEach((appointment) => {
9271
+ const costData = calculateAppointmentCost(appointment);
9272
+ if (costData.source === "finalbilling") {
9273
+ totalTax += costData.tax || 0;
9274
+ totalSubtotal += costData.subtotal || 0;
9275
+ } else {
9276
+ totalSubtotal += costData.cost;
9277
+ }
9278
+ });
9279
+ return {
9280
+ totalRevenue,
9281
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
9282
+ totalAppointments: appointments.length,
9283
+ completedAppointments: completed.length,
9284
+ currency,
9285
+ revenueByStatus,
9286
+ revenueByPaymentStatus,
9287
+ unpaidRevenue,
9288
+ refundedRevenue,
9289
+ totalTax,
9290
+ totalSubtotal
9291
+ };
9292
+ }
9293
+ // ==========================================
9294
+ // Product Usage Analytics
9295
+ // ==========================================
9296
+ /**
9297
+ * Get product usage metrics grouped by clinic, practitioner, procedure, or patient
9298
+ *
9299
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient'
9300
+ * @param dateRange - Optional date range filter
9301
+ * @param filters - Optional additional filters
9302
+ * @returns Grouped product usage metrics
9303
+ */
9304
+ async getProductUsageMetricsByEntity(groupBy, dateRange, filters) {
9305
+ const appointments = await this.fetchAppointments(filters, dateRange);
9306
+ return calculateGroupedProductUsageMetrics(appointments, groupBy);
9307
+ }
9308
+ /**
9309
+ * Get product usage metrics
9310
+ *
9311
+ * IMPORTANT: Only COMPLETED appointments are included in product usage calculations.
9312
+ * Products are only considered "used" when the procedure has been completed.
9313
+ * Confirmed, pending, canceled, and no-show appointments are excluded from product metrics.
9314
+ *
9315
+ * @param productId - Optional product ID (if not provided, returns all products)
9316
+ * @param dateRange - Optional date range filter
9317
+ * @param filters - Optional filters (e.g., clinicBranchId)
9318
+ * @returns Product usage metrics
9319
+ */
9320
+ async getProductUsageMetrics(productId, dateRange, filters) {
9321
+ const appointments = await this.fetchAppointments(filters, dateRange);
9322
+ const completed = getCompletedAppointments(appointments);
9323
+ const productMap = /* @__PURE__ */ new Map();
9324
+ completed.forEach((appointment) => {
9325
+ const products = extractProductUsage(appointment);
9326
+ const productsInThisAppointment = /* @__PURE__ */ new Set();
9327
+ products.forEach((product) => {
9328
+ if (productId && product.productId !== productId) {
9329
+ return;
9330
+ }
9331
+ if (!productMap.has(product.productId)) {
9332
+ productMap.set(product.productId, {
9333
+ name: product.productName,
9334
+ brandId: product.brandId,
9335
+ brandName: product.brandName,
9336
+ quantity: 0,
9337
+ revenue: 0,
9338
+ usageCount: 0,
9339
+ appointmentIds: /* @__PURE__ */ new Set(),
9340
+ procedureMap: /* @__PURE__ */ new Map()
9341
+ });
9342
+ }
9343
+ const productData = productMap.get(product.productId);
9344
+ productData.quantity += product.quantity;
9345
+ productData.revenue += product.subtotal;
9346
+ productsInThisAppointment.add(product.productId);
9347
+ });
9348
+ productsInThisAppointment.forEach((productId2) => {
9349
+ var _a;
9350
+ const productData = productMap.get(productId2);
9351
+ if (!productData.appointmentIds.has(appointment.id)) {
9352
+ productData.appointmentIds.add(appointment.id);
9353
+ productData.usageCount++;
9354
+ const procId = appointment.procedureId;
9355
+ const procName = ((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown";
9356
+ if (productData.procedureMap.has(procId)) {
9357
+ const procData = productData.procedureMap.get(procId);
9358
+ procData.count++;
9359
+ const appointmentProducts = products.filter((p) => p.productId === productId2);
9360
+ procData.quantity += appointmentProducts.reduce((sum, p) => sum + p.quantity, 0);
9361
+ } else {
9362
+ const appointmentProducts = products.filter((p) => p.productId === productId2);
9363
+ const totalQuantity = appointmentProducts.reduce((sum, p) => sum + p.quantity, 0);
9364
+ productData.procedureMap.set(procId, {
9365
+ name: procName,
9366
+ count: 1,
9367
+ quantity: totalQuantity
9368
+ });
9369
+ }
9370
+ }
9371
+ });
9372
+ });
9373
+ const results = Array.from(productMap.entries()).map(([productId2, data]) => ({
9374
+ productId: productId2,
9375
+ productName: data.name,
9376
+ brandId: data.brandId,
9377
+ brandName: data.brandName,
9378
+ totalQuantity: data.quantity,
9379
+ totalRevenue: data.revenue,
9380
+ averagePrice: data.usageCount > 0 ? data.revenue / data.quantity : 0,
9381
+ currency: "CHF",
9382
+ // Could be extracted from products
9383
+ usageCount: data.usageCount,
9384
+ averageQuantityPerAppointment: data.usageCount > 0 ? data.quantity / data.usageCount : 0,
9385
+ usageByProcedure: Array.from(data.procedureMap.entries()).map(([procId, procData]) => ({
9386
+ procedureId: procId,
9387
+ procedureName: procData.name,
9388
+ count: procData.count,
9389
+ totalQuantity: procData.quantity
9390
+ }))
9391
+ }));
9392
+ return productId ? results[0] : results;
9393
+ }
9394
+ // ==========================================
9395
+ // Patient Analytics
9396
+ // ==========================================
9397
+ /**
9398
+ * Get patient behavior metrics grouped by clinic, practitioner, procedure, or technology
9399
+ * Shows patient no-show and cancellation patterns per entity
9400
+ *
9401
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'technology'
9402
+ * @param dateRange - Optional date range filter
9403
+ * @param filters - Optional additional filters
9404
+ * @returns Grouped patient behavior metrics
9405
+ */
9406
+ async getPatientBehaviorMetricsByEntity(groupBy, dateRange, filters) {
9407
+ const appointments = await this.fetchAppointments(filters, dateRange);
9408
+ return calculateGroupedPatientBehaviorMetrics(appointments, groupBy);
9409
+ }
9410
+ /**
9411
+ * Get patient analytics
9412
+ *
9413
+ * @param patientId - Optional patient ID (if not provided, returns aggregate)
9414
+ * @param dateRange - Optional date range filter
9415
+ * @returns Patient analytics
9416
+ */
9417
+ async getPatientAnalytics(patientId, dateRange) {
9418
+ const appointments = await this.fetchAppointments(patientId ? { patientId } : void 0, dateRange);
9419
+ if (patientId) {
9420
+ return this.calculatePatientAnalytics(appointments, patientId);
9421
+ }
9422
+ const patientMap = /* @__PURE__ */ new Map();
9423
+ appointments.forEach((appointment) => {
9424
+ const patId = appointment.patientId;
9425
+ if (!patientMap.has(patId)) {
9426
+ patientMap.set(patId, []);
9427
+ }
9428
+ patientMap.get(patId).push(appointment);
9429
+ });
9430
+ return Array.from(patientMap.entries()).map(
9431
+ ([patId, patAppointments]) => this.calculatePatientAnalytics(patAppointments, patId)
9432
+ );
9433
+ }
9434
+ /**
9435
+ * Calculate analytics for a specific patient
9436
+ */
9437
+ calculatePatientAnalytics(appointments, patientId) {
9438
+ var _a;
9439
+ const completed = getCompletedAppointments(appointments);
9440
+ const canceled = getCanceledAppointments(appointments);
9441
+ const noShow = getNoShowAppointments(appointments);
9442
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
9443
+ const appointmentDates = appointments.map((a) => a.appointmentStartTime.toDate()).sort((a, b) => a.getTime() - b.getTime());
9444
+ const firstAppointmentDate = appointmentDates.length > 0 ? appointmentDates[0] : null;
9445
+ const lastAppointmentDate = appointmentDates.length > 0 ? appointmentDates[appointmentDates.length - 1] : null;
9446
+ let averageDaysBetween = null;
9447
+ if (appointmentDates.length > 1) {
9448
+ const intervals = [];
9449
+ for (let i = 1; i < appointmentDates.length; i++) {
9450
+ const diffMs = appointmentDates[i].getTime() - appointmentDates[i - 1].getTime();
9451
+ intervals.push(diffMs / (1e3 * 60 * 60 * 24));
9452
+ }
9453
+ averageDaysBetween = intervals.reduce((a, b) => a + b, 0) / intervals.length;
9454
+ }
9455
+ const uniquePractitioners = new Set(appointments.map((a) => a.practitionerId));
9456
+ const uniqueClinics = new Set(appointments.map((a) => a.clinicBranchId));
9457
+ const procedureMap = /* @__PURE__ */ new Map();
9458
+ completed.forEach((appointment) => {
9459
+ var _a2, _b;
9460
+ const procId = appointment.procedureId;
9461
+ const procName = ((_a2 = appointment.procedureInfo) == null ? void 0 : _a2.name) || "Unknown";
9462
+ procedureMap.set(procId, {
9463
+ name: procName,
9464
+ count: (((_b = procedureMap.get(procId)) == null ? void 0 : _b.count) || 0) + 1
9465
+ });
9466
+ });
9467
+ const favoriteProcedures = Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
9468
+ procedureId,
9469
+ procedureName: data.name,
9470
+ count: data.count
9471
+ })).sort((a, b) => b.count - a.count).slice(0, 5);
9472
+ const patientName = appointments.length > 0 ? ((_a = appointments[0].patientInfo) == null ? void 0 : _a.fullName) || "Unknown" : "Unknown";
9473
+ return {
9474
+ patientId,
9475
+ patientName,
9476
+ totalAppointments: appointments.length,
9477
+ completedAppointments: completed.length,
9478
+ canceledAppointments: canceled.length,
9479
+ noShowAppointments: noShow.length,
9480
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
9481
+ noShowRate: calculatePercentage(noShow.length, appointments.length),
9482
+ totalRevenue,
9483
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
9484
+ currency,
9485
+ lifetimeValue: totalRevenue,
9486
+ firstAppointmentDate,
9487
+ lastAppointmentDate,
9488
+ averageDaysBetweenAppointments: averageDaysBetween ? Math.round(averageDaysBetween) : null,
9489
+ uniquePractitioners: uniquePractitioners.size,
9490
+ uniqueClinics: uniqueClinics.size,
9491
+ favoriteProcedures
9492
+ };
9493
+ }
9494
+ // ==========================================
9495
+ // Dashboard Analytics
9496
+ // ==========================================
9497
+ /**
9498
+ * Determines analytics period from date range
9499
+ */
9500
+ determinePeriodFromDateRange(dateRange) {
9501
+ const diffMs = dateRange.end.getTime() - dateRange.start.getTime();
9502
+ const diffDays = diffMs / (1e3 * 60 * 60 * 24);
9503
+ if (diffDays <= 1) return "daily";
9504
+ if (diffDays <= 7) return "weekly";
9505
+ if (diffDays <= 31) return "monthly";
9506
+ if (diffDays <= 365) return "yearly";
9507
+ return "all_time";
9508
+ }
9509
+ /**
9510
+ * Get comprehensive dashboard data
9511
+ * First checks for stored analytics, then calculates if not available or stale
9512
+ *
9513
+ * @param filters - Optional filters
9514
+ * @param dateRange - Optional date range filter
9515
+ * @param options - Options for reading stored analytics
9516
+ * @returns Complete dashboard analytics
9517
+ */
9518
+ async getDashboardData(filters, dateRange, options) {
9519
+ if ((filters == null ? void 0 : filters.clinicBranchId) && dateRange && (options == null ? void 0 : options.useCache) !== false) {
9520
+ const period = this.determinePeriodFromDateRange(dateRange);
9521
+ const stored = await readStoredDashboardAnalytics(
9522
+ this.db,
9523
+ filters.clinicBranchId,
9524
+ { ...options, period }
9525
+ );
9526
+ if (stored) {
9527
+ const { metadata, ...analytics } = stored;
9528
+ return analytics;
9529
+ }
9530
+ }
9531
+ const appointments = await this.fetchAppointments(filters, dateRange);
9532
+ const completed = getCompletedAppointments(appointments);
9533
+ const canceled = getCanceledAppointments(appointments);
9534
+ const noShow = getNoShowAppointments(appointments);
9535
+ const pending = appointments.filter((a) => a.status === "pending" /* PENDING */);
9536
+ const confirmed = appointments.filter((a) => a.status === "confirmed" /* CONFIRMED */);
9537
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
9538
+ const uniquePatients = new Set(appointments.map((a) => a.patientId));
9539
+ const uniquePractitioners = new Set(appointments.map((a) => a.practitionerId));
9540
+ const uniqueProcedures = new Set(appointments.map((a) => a.procedureId));
9541
+ const practitionerMetrics = await Promise.all(
9542
+ Array.from(uniquePractitioners).slice(0, 5).map((practitionerId) => this.getPractitionerAnalytics(practitionerId, dateRange))
9543
+ );
9544
+ const procedureMetricsResults = await Promise.all(
9545
+ Array.from(uniqueProcedures).slice(0, 5).map((procedureId) => this.getProcedureAnalytics(procedureId, dateRange))
9546
+ );
9547
+ const procedureMetrics = procedureMetricsResults.filter(
9548
+ (result) => !Array.isArray(result)
9549
+ );
9550
+ const cancellationMetrics = await this.getCancellationMetrics("clinic", dateRange);
9551
+ const noShowMetrics = await this.getNoShowMetrics("clinic", dateRange);
9552
+ const timeEfficiency = await this.getTimeEfficiencyMetrics(filters, dateRange);
9553
+ const productMetrics = await this.getProductUsageMetrics(void 0, dateRange);
9554
+ const topProducts = Array.isArray(productMetrics) ? productMetrics.sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, 5) : [];
9555
+ const recentActivity = appointments.sort((a, b) => b.appointmentStartTime.toMillis() - a.appointmentStartTime.toMillis()).slice(0, 10).map((appointment) => {
9556
+ var _a, _b, _c, _d, _e;
9557
+ let type = "appointment";
9558
+ let description = "";
9559
+ if (appointment.status === "completed" /* COMPLETED */) {
9560
+ type = "completion";
9561
+ description = `Appointment completed: ${((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown procedure"}`;
9562
+ } else if (appointment.status === "canceled_patient" /* CANCELED_PATIENT */ || appointment.status === "canceled_clinic" /* CANCELED_CLINIC */ || appointment.status === "canceled_patient_rescheduled" /* CANCELED_PATIENT_RESCHEDULED */) {
9563
+ type = "cancellation";
9564
+ description = `Appointment canceled: ${((_b = appointment.procedureInfo) == null ? void 0 : _b.name) || "Unknown procedure"}`;
9565
+ } else if (appointment.status === "no_show" /* NO_SHOW */) {
9566
+ type = "no_show";
9567
+ description = `No-show: ${((_c = appointment.procedureInfo) == null ? void 0 : _c.name) || "Unknown procedure"}`;
9568
+ } else {
9569
+ description = `Appointment ${appointment.status}: ${((_d = appointment.procedureInfo) == null ? void 0 : _d.name) || "Unknown procedure"}`;
9570
+ }
9571
+ return {
9572
+ type,
9573
+ date: appointment.appointmentStartTime.toDate(),
9574
+ description,
9575
+ entityId: appointment.practitionerId,
9576
+ entityName: ((_e = appointment.practitionerInfo) == null ? void 0 : _e.name) || "Unknown"
9577
+ };
9578
+ });
9579
+ return {
9580
+ overview: {
9581
+ totalAppointments: appointments.length,
9582
+ completedAppointments: completed.length,
9583
+ canceledAppointments: canceled.length,
9584
+ noShowAppointments: noShow.length,
9585
+ pendingAppointments: pending.length,
9586
+ confirmedAppointments: confirmed.length,
9587
+ totalRevenue,
9588
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
9589
+ currency,
9590
+ uniquePatients: uniquePatients.size,
9591
+ uniquePractitioners: uniquePractitioners.size,
9592
+ uniqueProcedures: uniqueProcedures.size,
9593
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
9594
+ noShowRate: calculatePercentage(noShow.length, appointments.length)
9595
+ },
9596
+ practitionerMetrics: Array.isArray(practitionerMetrics) ? practitionerMetrics : [],
9597
+ procedureMetrics: Array.isArray(procedureMetrics) ? procedureMetrics : [],
9598
+ cancellationMetrics: Array.isArray(cancellationMetrics) ? cancellationMetrics[0] : cancellationMetrics,
9599
+ noShowMetrics: Array.isArray(noShowMetrics) ? noShowMetrics[0] : noShowMetrics,
9600
+ revenueTrends: [],
9601
+ // TODO: Implement revenue trends
9602
+ timeEfficiency,
9603
+ topProducts,
9604
+ recentActivity
9605
+ };
9606
+ }
9607
+ /**
9608
+ * Calculate revenue trends over time
9609
+ * Groups appointments by week/month/quarter/year and calculates revenue metrics
9610
+ *
9611
+ * @param dateRange - Date range for trend analysis (must align with period boundaries)
9612
+ * @param period - Period type (week, month, quarter, year)
9613
+ * @param filters - Optional filters for clinic, practitioner, procedure, patient
9614
+ * @param groupBy - Optional entity type to group trends by (clinic, practitioner, procedure, technology, patient)
9615
+ * @returns Array of revenue trends with percentage changes
9616
+ */
9617
+ async getRevenueTrends(dateRange, period, filters, groupBy) {
9618
+ const appointments = await this.fetchAppointments(filters);
9619
+ const filtered = filterByDateRange(appointments, dateRange);
9620
+ if (filtered.length === 0) {
9621
+ return [];
9622
+ }
9623
+ if (groupBy) {
9624
+ return this.getGroupedRevenueTrends(filtered, dateRange, period, groupBy);
9625
+ }
9626
+ const periodMap = groupAppointmentsByPeriod(filtered, period);
9627
+ const periods = generatePeriods(dateRange.start, dateRange.end, period);
9628
+ const trends = [];
9629
+ let previousRevenue = 0;
9630
+ let previousAppointmentCount = 0;
9631
+ periods.forEach((periodInfo) => {
9632
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
9633
+ const completed = getCompletedAppointments(periodAppointments);
9634
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
9635
+ const appointmentCount = completed.length;
9636
+ const averageRevenue = appointmentCount > 0 ? totalRevenue / appointmentCount : 0;
9637
+ const trend = {
9638
+ period: periodInfo.period,
9639
+ startDate: periodInfo.startDate,
9640
+ endDate: periodInfo.endDate,
9641
+ revenue: totalRevenue,
9642
+ appointmentCount,
9643
+ averageRevenue,
9644
+ currency
9645
+ };
9646
+ if (previousRevenue > 0 || previousAppointmentCount > 0) {
9647
+ const revenueChange = getTrendChange(totalRevenue, previousRevenue);
9648
+ trend.previousPeriod = {
9649
+ revenue: previousRevenue,
9650
+ appointmentCount: previousAppointmentCount,
9651
+ percentageChange: revenueChange.percentageChange,
9652
+ direction: revenueChange.direction
9653
+ };
9654
+ }
9655
+ trends.push(trend);
9656
+ previousRevenue = totalRevenue;
9657
+ previousAppointmentCount = appointmentCount;
9658
+ });
9659
+ return trends;
9660
+ }
9661
+ /**
9662
+ * Calculate revenue trends grouped by entity
9663
+ */
9664
+ async getGroupedRevenueTrends(appointments, dateRange, period, groupBy) {
9665
+ const periodMap = groupAppointmentsByPeriod(appointments, period);
9666
+ const periods = generatePeriods(dateRange.start, dateRange.end, period);
9667
+ const trends = [];
9668
+ periods.forEach((periodInfo) => {
9669
+ var _a;
9670
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
9671
+ if (periodAppointments.length === 0) return;
9672
+ const groupedMetrics = calculateGroupedRevenueMetrics(periodAppointments, groupBy);
9673
+ const totalRevenue = groupedMetrics.reduce((sum, m) => sum + m.totalRevenue, 0);
9674
+ const totalAppointments = groupedMetrics.reduce((sum, m) => sum + m.totalAppointments, 0);
9675
+ const currency = ((_a = groupedMetrics[0]) == null ? void 0 : _a.currency) || "CHF";
9676
+ const averageRevenue = totalAppointments > 0 ? totalRevenue / totalAppointments : 0;
9677
+ trends.push({
9678
+ period: periodInfo.period,
9679
+ startDate: periodInfo.startDate,
9680
+ endDate: periodInfo.endDate,
9681
+ revenue: totalRevenue,
9682
+ appointmentCount: totalAppointments,
9683
+ averageRevenue,
9684
+ currency
9685
+ });
9686
+ });
9687
+ for (let i = 1; i < trends.length; i++) {
9688
+ const current = trends[i];
9689
+ const previous = trends[i - 1];
9690
+ const revenueChange = getTrendChange(current.revenue, previous.revenue);
9691
+ current.previousPeriod = {
9692
+ revenue: previous.revenue,
9693
+ appointmentCount: previous.appointmentCount,
9694
+ percentageChange: revenueChange.percentageChange,
9695
+ direction: revenueChange.direction
9696
+ };
9697
+ }
9698
+ return trends;
9699
+ }
9700
+ /**
9701
+ * Calculate duration/efficiency trends over time
9702
+ *
9703
+ * @param dateRange - Date range for trend analysis
9704
+ * @param period - Period type (week, month, quarter, year)
9705
+ * @param filters - Optional filters
9706
+ * @param groupBy - Optional entity type to group trends by
9707
+ * @returns Array of duration trends with percentage changes
9708
+ */
9709
+ async getDurationTrends(dateRange, period, filters, groupBy) {
9710
+ const appointments = await this.fetchAppointments(filters);
9711
+ const filtered = filterByDateRange(appointments, dateRange);
9712
+ if (filtered.length === 0) {
9713
+ return [];
9714
+ }
9715
+ const periodMap = groupAppointmentsByPeriod(filtered, period);
9716
+ const periods = generatePeriods(dateRange.start, dateRange.end, period);
9717
+ const trends = [];
9718
+ let previousEfficiency = 0;
9719
+ let previousBookedDuration = 0;
9720
+ let previousActualDuration = 0;
9721
+ periods.forEach((periodInfo) => {
9722
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
9723
+ const completed = getCompletedAppointments(periodAppointments);
9724
+ if (groupBy) {
9725
+ const groupedMetrics = calculateGroupedTimeEfficiencyMetrics(completed, groupBy);
9726
+ if (groupedMetrics.length === 0) return;
9727
+ const totalAppointments = groupedMetrics.reduce((sum, m) => sum + m.totalAppointments, 0);
9728
+ const weightedBooked = groupedMetrics.reduce(
9729
+ (sum, m) => sum + m.averageBookedDuration * m.totalAppointments,
9730
+ 0
9731
+ );
9732
+ const weightedActual = groupedMetrics.reduce(
9733
+ (sum, m) => sum + m.averageActualDuration * m.totalAppointments,
9734
+ 0
9735
+ );
9736
+ const weightedEfficiency = groupedMetrics.reduce(
9737
+ (sum, m) => sum + m.averageEfficiency * m.totalAppointments,
9738
+ 0
9739
+ );
9740
+ const averageBookedDuration = totalAppointments > 0 ? weightedBooked / totalAppointments : 0;
9741
+ const averageActualDuration = totalAppointments > 0 ? weightedActual / totalAppointments : 0;
9742
+ const averageEfficiency = totalAppointments > 0 ? weightedEfficiency / totalAppointments : 0;
9743
+ const trend = {
9744
+ period: periodInfo.period,
9745
+ startDate: periodInfo.startDate,
9746
+ endDate: periodInfo.endDate,
9747
+ averageBookedDuration,
9748
+ averageActualDuration,
9749
+ averageEfficiency,
9750
+ appointmentCount: totalAppointments
9751
+ };
9752
+ if (previousEfficiency > 0) {
9753
+ const efficiencyChange = getTrendChange(averageEfficiency, previousEfficiency);
9754
+ trend.previousPeriod = {
9755
+ averageBookedDuration: previousBookedDuration,
9756
+ averageActualDuration: previousActualDuration,
9757
+ averageEfficiency: previousEfficiency,
9758
+ efficiencyPercentageChange: efficiencyChange.percentageChange,
9759
+ direction: efficiencyChange.direction
9760
+ };
9761
+ }
9762
+ trends.push(trend);
9763
+ previousEfficiency = averageEfficiency;
9764
+ previousBookedDuration = averageBookedDuration;
9765
+ previousActualDuration = averageActualDuration;
9766
+ } else {
9767
+ const timeMetrics = calculateAverageTimeMetrics(completed);
9768
+ const trend = {
9769
+ period: periodInfo.period,
9770
+ startDate: periodInfo.startDate,
9771
+ endDate: periodInfo.endDate,
9772
+ averageBookedDuration: timeMetrics.averageBookedDuration,
9773
+ averageActualDuration: timeMetrics.averageActualDuration,
9774
+ averageEfficiency: timeMetrics.averageEfficiency,
9775
+ appointmentCount: timeMetrics.appointmentsWithActualTime
9776
+ };
9777
+ if (previousEfficiency > 0) {
9778
+ const efficiencyChange = getTrendChange(timeMetrics.averageEfficiency, previousEfficiency);
9779
+ trend.previousPeriod = {
9780
+ averageBookedDuration: previousBookedDuration,
9781
+ averageActualDuration: previousActualDuration,
9782
+ averageEfficiency: previousEfficiency,
9783
+ efficiencyPercentageChange: efficiencyChange.percentageChange,
9784
+ direction: efficiencyChange.direction
9785
+ };
9786
+ }
9787
+ trends.push(trend);
9788
+ previousEfficiency = timeMetrics.averageEfficiency;
9789
+ previousBookedDuration = timeMetrics.averageBookedDuration;
9790
+ previousActualDuration = timeMetrics.averageActualDuration;
9791
+ }
9792
+ });
9793
+ return trends;
9794
+ }
9795
+ /**
9796
+ * Calculate appointment count trends over time
9797
+ *
9798
+ * @param dateRange - Date range for trend analysis
9799
+ * @param period - Period type (week, month, quarter, year)
9800
+ * @param filters - Optional filters
9801
+ * @param groupBy - Optional entity type to group trends by
9802
+ * @returns Array of appointment trends with percentage changes
9803
+ */
9804
+ async getAppointmentTrends(dateRange, period, filters, groupBy) {
9805
+ const appointments = await this.fetchAppointments(filters);
9806
+ const filtered = filterByDateRange(appointments, dateRange);
9807
+ if (filtered.length === 0) {
9808
+ return [];
9809
+ }
9810
+ const periodMap = groupAppointmentsByPeriod(filtered, period);
9811
+ const periods = generatePeriods(dateRange.start, dateRange.end, period);
9812
+ const trends = [];
9813
+ let previousTotal = 0;
9814
+ let previousCompleted = 0;
9815
+ periods.forEach((periodInfo) => {
9816
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
9817
+ const completed = getCompletedAppointments(periodAppointments);
9818
+ const canceled = getCanceledAppointments(periodAppointments);
9819
+ const noShow = getNoShowAppointments(periodAppointments);
9820
+ const pending = periodAppointments.filter((a) => a.status === "pending" /* PENDING */);
9821
+ const confirmed = periodAppointments.filter((a) => a.status === "confirmed" /* CONFIRMED */);
9822
+ const trend = {
9823
+ period: periodInfo.period,
9824
+ startDate: periodInfo.startDate,
9825
+ endDate: periodInfo.endDate,
9826
+ totalAppointments: periodAppointments.length,
9827
+ completedAppointments: completed.length,
9828
+ canceledAppointments: canceled.length,
9829
+ noShowAppointments: noShow.length,
9830
+ pendingAppointments: pending.length,
9831
+ confirmedAppointments: confirmed.length
9832
+ };
9833
+ if (previousTotal > 0) {
9834
+ const totalChange = getTrendChange(periodAppointments.length, previousTotal);
9835
+ trend.previousPeriod = {
9836
+ totalAppointments: previousTotal,
9837
+ completedAppointments: previousCompleted,
9838
+ percentageChange: totalChange.percentageChange,
9839
+ direction: totalChange.direction
9840
+ };
9841
+ }
9842
+ trends.push(trend);
9843
+ previousTotal = periodAppointments.length;
9844
+ previousCompleted = completed.length;
9845
+ });
9846
+ return trends;
9847
+ }
9848
+ /**
9849
+ * Calculate cancellation and no-show rate trends over time
9850
+ *
9851
+ * @param dateRange - Date range for trend analysis
9852
+ * @param period - Period type (week, month, quarter, year)
9853
+ * @param filters - Optional filters
9854
+ * @param groupBy - Optional entity type to group trends by
9855
+ * @returns Array of cancellation rate trends with percentage changes
9856
+ */
9857
+ async getCancellationRateTrends(dateRange, period, filters, groupBy) {
9858
+ const appointments = await this.fetchAppointments(filters);
9859
+ const filtered = filterByDateRange(appointments, dateRange);
9860
+ if (filtered.length === 0) {
9861
+ return [];
9862
+ }
9863
+ const periodMap = groupAppointmentsByPeriod(filtered, period);
9864
+ const periods = generatePeriods(dateRange.start, dateRange.end, period);
9865
+ const trends = [];
9866
+ let previousCancellationRate = 0;
9867
+ let previousNoShowRate = 0;
9868
+ periods.forEach((periodInfo) => {
9869
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
9870
+ const canceled = getCanceledAppointments(periodAppointments);
9871
+ const noShow = getNoShowAppointments(periodAppointments);
9872
+ const cancellationRate = calculatePercentage(canceled.length, periodAppointments.length);
9873
+ const noShowRate = calculatePercentage(noShow.length, periodAppointments.length);
9874
+ const trend = {
9875
+ period: periodInfo.period,
9876
+ startDate: periodInfo.startDate,
9877
+ endDate: periodInfo.endDate,
9878
+ cancellationRate,
9879
+ noShowRate,
9880
+ totalAppointments: periodAppointments.length,
9881
+ canceledAppointments: canceled.length,
9882
+ noShowAppointments: noShow.length
9883
+ };
9884
+ if (previousCancellationRate > 0 || previousNoShowRate > 0) {
9885
+ const cancellationChange = getTrendChange(cancellationRate, previousCancellationRate);
9886
+ const noShowChange = getTrendChange(noShowRate, previousNoShowRate);
9887
+ trend.previousPeriod = {
9888
+ cancellationRate: previousCancellationRate,
9889
+ noShowRate: previousNoShowRate,
9890
+ cancellationRateChange: cancellationChange.percentageChange,
9891
+ noShowRateChange: noShowChange.percentageChange,
9892
+ direction: cancellationChange.direction
9893
+ // Use cancellation direction as primary
9894
+ };
9895
+ }
9896
+ trends.push(trend);
9897
+ previousCancellationRate = cancellationRate;
9898
+ previousNoShowRate = noShowRate;
9899
+ });
9900
+ return trends;
9901
+ }
9902
+ // ==========================================
9903
+ // Review Analytics Methods
9904
+ // ==========================================
9905
+ /**
9906
+ * Get review metrics for a specific entity (practitioner, procedure, etc.)
9907
+ */
9908
+ async getReviewMetricsByEntity(entityType, entityId, dateRange, filters) {
9909
+ return this.reviewAnalyticsService.getReviewMetricsByEntity(entityType, entityId, dateRange, filters);
9910
+ }
9911
+ /**
9912
+ * Get review metrics for multiple entities (grouped)
9913
+ */
9914
+ async getReviewMetricsByEntities(entityType, dateRange, filters) {
9915
+ return this.reviewAnalyticsService.getReviewMetricsByEntities(entityType, dateRange, filters);
9916
+ }
9917
+ /**
9918
+ * Get overall review averages for comparison
9919
+ */
9920
+ async getOverallReviewAverages(dateRange, filters) {
9921
+ return this.reviewAnalyticsService.getOverallReviewAverages(dateRange, filters);
9922
+ }
9923
+ /**
9924
+ * Get review details for a specific entity
9925
+ */
9926
+ async getReviewDetails(entityType, entityId, dateRange, filters) {
9927
+ return this.reviewAnalyticsService.getReviewDetails(entityType, entityId, dateRange, filters);
9928
+ }
9929
+ /**
9930
+ * Calculate review trends over time
9931
+ * Groups reviews by period and calculates rating and recommendation metrics
9932
+ *
9933
+ * @param dateRange - Date range for trend analysis
9934
+ * @param period - Period type (week, month, quarter, year)
9935
+ * @param filters - Optional filters for clinic, practitioner, procedure
9936
+ * @param entityType - Optional entity type to group trends by
9937
+ * @returns Array of review trends with percentage changes
9938
+ */
9939
+ async getReviewTrends(dateRange, period, filters, entityType) {
9940
+ return this.reviewAnalyticsService.getReviewTrends(dateRange, period, filters, entityType);
9941
+ }
9942
+ };
9943
+
9944
+ // src/admin/analytics/analytics.admin.service.ts
9945
+ var AnalyticsAdminService = class {
9946
+ /**
9947
+ * Creates a new AnalyticsAdminService instance
9948
+ *
9949
+ * @param firestore - Admin Firestore instance (optional, defaults to admin.firestore())
9950
+ */
9951
+ constructor(firestore19) {
9952
+ this.db = firestore19 || admin14.firestore();
9953
+ const mockApp = {
9954
+ name: "[DEFAULT]",
9955
+ options: {},
9956
+ automaticDataCollectionEnabled: false
9957
+ };
9958
+ const mockAuth = {};
9959
+ const appointmentService = this.createAppointmentServiceAdapter();
9960
+ this.analyticsService = new AnalyticsService(
9961
+ this.db,
9962
+ // Cast admin Firestore to client Firestore type
9963
+ mockAuth,
9964
+ mockApp,
9965
+ appointmentService
9966
+ );
9967
+ }
9968
+ /**
9969
+ * Creates an adapter for AppointmentService to work with admin SDK
9970
+ */
9971
+ createAppointmentServiceAdapter() {
9972
+ return {
9973
+ searchAppointments: async (params) => {
9974
+ let query3 = this.db.collection(APPOINTMENTS_COLLECTION);
9975
+ if (params.clinicBranchId) {
9976
+ query3 = query3.where("clinicBranchId", "==", params.clinicBranchId);
9977
+ }
9978
+ if (params.practitionerId) {
9979
+ query3 = query3.where("practitionerId", "==", params.practitionerId);
9980
+ }
9981
+ if (params.procedureId) {
9982
+ query3 = query3.where("procedureId", "==", params.procedureId);
9983
+ }
9984
+ if (params.patientId) {
9985
+ query3 = query3.where("patientId", "==", params.patientId);
9986
+ }
9987
+ if (params.startDate) {
9988
+ const startDate = params.startDate instanceof Date ? params.startDate : params.startDate.toDate();
9989
+ const startTimestamp = admin14.firestore.Timestamp.fromDate(startDate);
9990
+ query3 = query3.where("appointmentStartTime", ">=", startTimestamp);
9991
+ }
9992
+ if (params.endDate) {
9993
+ const endDate = params.endDate instanceof Date ? params.endDate : params.endDate.toDate();
9994
+ const endTimestamp = admin14.firestore.Timestamp.fromDate(endDate);
9995
+ query3 = query3.where("appointmentStartTime", "<=", endTimestamp);
9996
+ }
9997
+ const snapshot = await query3.get();
9998
+ const appointments = snapshot.docs.map((doc3) => ({
9999
+ id: doc3.id,
10000
+ ...doc3.data()
10001
+ }));
10002
+ return {
10003
+ appointments,
10004
+ total: appointments.length
10005
+ };
10006
+ }
10007
+ };
10008
+ }
10009
+ // Delegate all methods to the underlying AnalyticsService
10010
+ // We expose them here so they can be called with admin SDK context
10011
+ async getPractitionerAnalytics(practitionerId, dateRange, options) {
10012
+ return this.analyticsService.getPractitionerAnalytics(practitionerId, dateRange, options);
10013
+ }
10014
+ async getProcedureAnalytics(procedureId, dateRange, options) {
10015
+ return this.analyticsService.getProcedureAnalytics(procedureId, dateRange, options);
10016
+ }
10017
+ async getTimeEfficiencyMetrics(filters, dateRange, options) {
10018
+ return this.analyticsService.getTimeEfficiencyMetrics(filters, dateRange, options);
10019
+ }
10020
+ async getTimeEfficiencyMetricsByEntity(groupBy, dateRange, filters) {
10021
+ return this.analyticsService.getTimeEfficiencyMetricsByEntity(groupBy, dateRange, filters);
10022
+ }
10023
+ async getCancellationMetrics(groupBy, dateRange, options) {
10024
+ return this.analyticsService.getCancellationMetrics(groupBy, dateRange, options);
10025
+ }
10026
+ async getNoShowMetrics(groupBy, dateRange, options) {
10027
+ return this.analyticsService.getNoShowMetrics(groupBy, dateRange, options);
10028
+ }
10029
+ async getRevenueMetrics(filters, dateRange, options) {
10030
+ return this.analyticsService.getRevenueMetrics(filters, dateRange, options);
10031
+ }
10032
+ async getRevenueMetricsByEntity(groupBy, dateRange, filters) {
10033
+ return this.analyticsService.getRevenueMetricsByEntity(groupBy, dateRange, filters);
10034
+ }
10035
+ async getProductUsageMetrics(productId, dateRange) {
10036
+ return this.analyticsService.getProductUsageMetrics(productId, dateRange);
10037
+ }
10038
+ async getProductUsageMetricsByEntity(groupBy, dateRange, filters) {
10039
+ return this.analyticsService.getProductUsageMetricsByEntity(groupBy, dateRange, filters);
10040
+ }
10041
+ async getPatientAnalytics(patientId, dateRange) {
10042
+ return this.analyticsService.getPatientAnalytics(patientId, dateRange);
10043
+ }
10044
+ async getPatientBehaviorMetricsByEntity(groupBy, dateRange, filters) {
10045
+ return this.analyticsService.getPatientBehaviorMetricsByEntity(groupBy, dateRange, filters);
10046
+ }
10047
+ async getClinicAnalytics(clinicBranchId, dateRange) {
10048
+ const dashboard = await this.analyticsService.getDashboardData(
10049
+ { clinicBranchId },
10050
+ dateRange
10051
+ );
10052
+ const clinicDoc = await this.db.collection("clinics").doc(clinicBranchId).get();
10053
+ const clinicData = clinicDoc.data();
10054
+ const clinicName = (clinicData == null ? void 0 : clinicData.name) || "Unknown";
10055
+ return {
10056
+ clinicBranchId,
10057
+ clinicName,
10058
+ totalAppointments: dashboard.overview.totalAppointments,
10059
+ completedAppointments: dashboard.overview.completedAppointments,
10060
+ canceledAppointments: dashboard.overview.canceledAppointments,
10061
+ noShowAppointments: dashboard.overview.noShowAppointments,
10062
+ cancellationRate: dashboard.overview.cancellationRate,
10063
+ noShowRate: dashboard.overview.noShowRate,
10064
+ totalRevenue: dashboard.overview.totalRevenue,
10065
+ averageRevenuePerAppointment: dashboard.overview.averageRevenuePerAppointment,
10066
+ currency: dashboard.overview.currency,
10067
+ practitionerCount: dashboard.overview.uniquePractitioners,
10068
+ patientCount: dashboard.overview.uniquePatients,
10069
+ procedureCount: dashboard.overview.uniqueProcedures,
10070
+ topPractitioners: dashboard.practitionerMetrics.slice(0, 5).map((p) => ({
10071
+ practitionerId: p.practitionerId,
10072
+ practitionerName: p.practitionerName,
10073
+ appointmentCount: p.totalAppointments,
10074
+ revenue: p.totalRevenue
10075
+ })),
10076
+ topProcedures: dashboard.procedureMetrics.slice(0, 5).map((p) => ({
10077
+ procedureId: p.procedureId,
10078
+ procedureName: p.procedureName,
10079
+ appointmentCount: p.totalAppointments,
10080
+ revenue: p.totalRevenue
10081
+ }))
10082
+ };
10083
+ }
10084
+ async getDashboardData(filters, dateRange, options) {
10085
+ return this.analyticsService.getDashboardData(filters, dateRange, options);
10086
+ }
10087
+ /**
10088
+ * Expose fetchAppointments for direct access if needed
10089
+ * This method is used internally by AnalyticsService
10090
+ */
10091
+ async fetchAppointments(filters, dateRange) {
10092
+ return this.analyticsService.fetchAppointments(filters, dateRange);
10093
+ }
10094
+ };
10095
+
10096
+ // src/admin/booking/booking.calculator.ts
10097
+ import { Timestamp as Timestamp4 } from "firebase/firestore";
10098
+ import { DateTime as DateTime2 } from "luxon";
10099
+ var BookingAvailabilityCalculator = class {
10100
+ /**
10101
+ * Calculate available booking slots based on the provided data
10102
+ *
10103
+ * @param request - The request containing all necessary data for calculation
10104
+ * @returns Response with available booking slots
10105
+ */
10106
+ static calculateSlots(request) {
10107
+ const {
10108
+ clinic,
10109
+ practitioner,
10110
+ procedure,
10111
+ timeframe,
10112
+ clinicCalendarEvents,
10113
+ practitionerCalendarEvents,
10114
+ tz
10115
+ } = request;
10116
+ const schedulingIntervalMinutes = clinic.schedulingInterval || this.DEFAULT_INTERVAL_MINUTES;
10117
+ const procedureDurationMinutes = procedure.duration;
10118
+ console.log(
10119
+ `Calculating slots with interval: ${schedulingIntervalMinutes}min and procedure duration: ${procedureDurationMinutes}min`
10120
+ );
10121
+ let availableIntervals = [
10122
+ { start: timeframe.start, end: timeframe.end }
10123
+ ];
10124
+ availableIntervals = this.applyClinicWorkingHours(
10125
+ availableIntervals,
10126
+ clinic.workingHours,
10127
+ timeframe,
10128
+ tz
10129
+ );
10130
+ availableIntervals = this.subtractBlockingEvents(
10131
+ availableIntervals,
10132
+ clinicCalendarEvents
10133
+ );
10134
+ availableIntervals = this.applyPractitionerWorkingHours(
10135
+ availableIntervals,
10136
+ practitioner,
10137
+ clinic.id,
10138
+ timeframe,
10139
+ tz
10140
+ );
10141
+ availableIntervals = this.subtractPractitionerBusyTimes(
10142
+ availableIntervals,
10143
+ practitionerCalendarEvents
10144
+ );
10145
+ console.log(
10146
+ `After all filters, have ${availableIntervals.length} available intervals`
10147
+ );
10148
+ const availableSlots = this.generateAvailableSlots(
10149
+ availableIntervals,
10150
+ schedulingIntervalMinutes,
10151
+ procedureDurationMinutes,
10152
+ tz
10153
+ );
10154
+ return { availableSlots };
10155
+ }
10156
+ /**
10157
+ * Apply clinic working hours to available intervals
10158
+ *
10159
+ * @param intervals - Current available intervals
10160
+ * @param workingHours - Clinic working hours
10161
+ * @param timeframe - Overall timeframe being considered
10162
+ * @param tz - IANA timezone of the clinic
10163
+ * @returns Intervals filtered by clinic working hours
10164
+ */
10165
+ static applyClinicWorkingHours(intervals, workingHours, timeframe, tz) {
10166
+ if (!intervals.length) return [];
10167
+ console.log(
10168
+ `Applying clinic working hours to ${intervals.length} intervals`
10169
+ );
10170
+ const workingIntervals = this.createWorkingHoursIntervals(
10171
+ workingHours,
10172
+ timeframe.start.toDate(),
10173
+ timeframe.end.toDate(),
10174
+ tz
10175
+ );
10176
+ return this.intersectIntervals(intervals, workingIntervals);
10177
+ }
10178
+ /**
10179
+ * Create time intervals for working hours across multiple days
10180
+ *
10181
+ * @param workingHours - Working hours definition
10182
+ * @param startDate - Start date of the overall timeframe
10183
+ * @param endDate - End date of the overall timeframe
10184
+ * @param tz - IANA timezone of the clinic
10185
+ * @returns Array of time intervals representing working hours
10186
+ */
10187
+ static createWorkingHoursIntervals(workingHours, startDate, endDate, tz) {
10188
+ const workingIntervals = [];
10189
+ let start = DateTime2.fromMillis(startDate.getTime(), { zone: tz });
10190
+ const end = DateTime2.fromMillis(endDate.getTime(), { zone: tz });
10191
+ while (start <= end) {
10192
+ const dayOfWeek = start.weekday;
10193
+ const dayName = [
10194
+ "monday",
10195
+ "tuesday",
10196
+ "wednesday",
10197
+ "thursday",
10198
+ "friday",
10199
+ "saturday",
10200
+ "sunday"
10201
+ ][dayOfWeek - 1];
10202
+ if (dayName && workingHours[dayName]) {
10203
+ const daySchedule = workingHours[dayName];
10204
+ if (daySchedule) {
10205
+ const [openHours, openMinutes] = daySchedule.open.split(":").map(Number);
10206
+ const [closeHours, closeMinutes] = daySchedule.close.split(":").map(Number);
10207
+ let workStart = start.set({
10208
+ hour: openHours,
10209
+ minute: openMinutes,
10210
+ second: 0,
10211
+ millisecond: 0
10212
+ });
10213
+ let workEnd = start.set({
10214
+ hour: closeHours,
10215
+ minute: closeMinutes,
10216
+ second: 0,
10217
+ millisecond: 0
10218
+ });
10219
+ if (workEnd.toMillis() > startDate.getTime() && workStart.toMillis() < endDate.getTime()) {
10220
+ const intervalStart = workStart < DateTime2.fromMillis(startDate.getTime(), { zone: tz }) ? DateTime2.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
10221
+ const intervalEnd = workEnd > DateTime2.fromMillis(endDate.getTime(), { zone: tz }) ? DateTime2.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
10222
+ workingIntervals.push({
10223
+ start: Timestamp4.fromMillis(intervalStart.toMillis()),
10224
+ end: Timestamp4.fromMillis(intervalEnd.toMillis())
10225
+ });
10226
+ if (daySchedule.breaks && daySchedule.breaks.length > 0) {
10227
+ for (const breakTime of daySchedule.breaks) {
10228
+ const [breakStartHours, breakStartMinutes] = breakTime.start.split(":").map(Number);
10229
+ const [breakEndHours, breakEndMinutes] = breakTime.end.split(":").map(Number);
10230
+ const breakStart = start.set({
10231
+ hour: breakStartHours,
10232
+ minute: breakStartMinutes
10233
+ });
10234
+ const breakEnd = start.set({
10235
+ hour: breakEndHours,
10236
+ minute: breakEndMinutes
10237
+ });
10238
+ workingIntervals.splice(
10239
+ -1,
10240
+ 1,
10241
+ ...this.subtractInterval(
10242
+ workingIntervals[workingIntervals.length - 1],
10243
+ {
10244
+ start: Timestamp4.fromMillis(breakStart.toMillis()),
10245
+ end: Timestamp4.fromMillis(breakEnd.toMillis())
10246
+ }
10247
+ )
10248
+ );
10249
+ }
10250
+ }
10251
+ }
10252
+ }
10253
+ }
10254
+ start = start.plus({ days: 1 });
10255
+ }
10256
+ return workingIntervals;
10257
+ }
10258
+ /**
10259
+ * Subtract blocking events from available intervals
10260
+ *
10261
+ * @param intervals - Current available intervals
10262
+ * @param events - Calendar events to subtract
10263
+ * @returns Available intervals after removing blocking events
10264
+ */
10265
+ static subtractBlockingEvents(intervals, events) {
10266
+ if (!intervals.length) return [];
10267
+ console.log(`Subtracting ${events.length} blocking events`);
10268
+ const blockingEvents = events.filter(
10269
+ (event) => event.eventType === "blocking" /* BLOCKING */ || event.eventType === "break" /* BREAK */ || event.eventType === "free_day" /* FREE_DAY */
10270
+ );
10271
+ let result = [...intervals];
10272
+ for (const event of blockingEvents) {
10273
+ const { start, end } = event.eventTime;
10274
+ const blockingInterval = { start, end };
10275
+ const newResult = [];
10276
+ for (const interval of result) {
10277
+ const remainingIntervals = this.subtractInterval(
10278
+ interval,
10279
+ blockingInterval
10280
+ );
10281
+ newResult.push(...remainingIntervals);
10282
+ }
10283
+ result = newResult;
10284
+ }
10285
+ return result;
10286
+ }
10287
+ /**
10288
+ * Apply practitioner's specific working hours for the given clinic
10289
+ *
10290
+ * @param intervals - Current available intervals
10291
+ * @param practitioner - Practitioner object
10292
+ * @param clinicId - ID of the clinic
10293
+ * @param timeframe - Overall timeframe being considered
10294
+ * @param tz - IANA timezone of the clinic
10295
+ * @returns Intervals filtered by practitioner's working hours
10296
+ */
10297
+ static applyPractitionerWorkingHours(intervals, practitioner, clinicId, timeframe, tz) {
10298
+ if (!intervals.length) return [];
10299
+ console.log(`Applying practitioner working hours for clinic ${clinicId}`);
10300
+ const clinicWorkingHours = practitioner.clinicWorkingHours.find(
10301
+ (hours) => hours.clinicId === clinicId && hours.isActive
10302
+ );
10303
+ if (!clinicWorkingHours) {
10304
+ console.log(
10305
+ `No working hours found for practitioner at clinic ${clinicId}`
10306
+ );
10307
+ return [];
10308
+ }
10309
+ const workingIntervals = this.createPractitionerWorkingHoursIntervals(
10310
+ clinicWorkingHours.workingHours,
10311
+ timeframe.start.toDate(),
10312
+ timeframe.end.toDate(),
10313
+ tz
10314
+ );
10315
+ return this.intersectIntervals(intervals, workingIntervals);
7090
10316
  }
7091
10317
  /**
7092
10318
  * Create time intervals for practitioner's working hours across multiple days
@@ -7126,8 +10352,8 @@ var BookingAvailabilityCalculator = class {
7126
10352
  const intervalStart = workStart < DateTime2.fromMillis(startDate.getTime(), { zone: tz }) ? DateTime2.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
7127
10353
  const intervalEnd = workEnd > DateTime2.fromMillis(endDate.getTime(), { zone: tz }) ? DateTime2.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
7128
10354
  workingIntervals.push({
7129
- start: Timestamp.fromMillis(intervalStart.toMillis()),
7130
- end: Timestamp.fromMillis(intervalEnd.toMillis())
10355
+ start: Timestamp4.fromMillis(intervalStart.toMillis()),
10356
+ end: Timestamp4.fromMillis(intervalEnd.toMillis())
7131
10357
  });
7132
10358
  }
7133
10359
  }
@@ -7207,7 +10433,7 @@ var BookingAvailabilityCalculator = class {
7207
10433
  const isInFuture = slotStart >= earliestBookableTime;
7208
10434
  if (isInFuture && this.isSlotFullyAvailable(slotStart, slotEnd, intervals, tz)) {
7209
10435
  slots.push({
7210
- start: Timestamp.fromMillis(slotStart.toMillis())
10436
+ start: Timestamp4.fromMillis(slotStart.toMillis())
7211
10437
  });
7212
10438
  }
7213
10439
  slotStart = slotStart.plus({ minutes: intervalMinutes });
@@ -7326,13 +10552,13 @@ var BookingAvailabilityCalculator = class {
7326
10552
  BookingAvailabilityCalculator.DEFAULT_INTERVAL_MINUTES = 15;
7327
10553
 
7328
10554
  // src/admin/booking/booking.admin.ts
7329
- import * as admin15 from "firebase-admin";
10555
+ import * as admin16 from "firebase-admin";
7330
10556
 
7331
10557
  // src/admin/documentation-templates/document-manager.admin.ts
7332
- import * as admin14 from "firebase-admin";
10558
+ import * as admin15 from "firebase-admin";
7333
10559
  var DocumentManagerAdminService = class {
7334
- constructor(firestore18) {
7335
- this.db = firestore18;
10560
+ constructor(firestore19) {
10561
+ this.db = firestore19;
7336
10562
  }
7337
10563
  /**
7338
10564
  * Adds operations to a Firestore batch to initialize all linked forms for a new appointment
@@ -7435,10 +10661,10 @@ var DocumentManagerAdminService = class {
7435
10661
  };
7436
10662
  }
7437
10663
  const templateIds = technologyTemplates.map((t) => t.templateId);
7438
- const templatesSnapshot = await this.db.collection(DOCUMENTATION_TEMPLATES_COLLECTION).where(admin14.firestore.FieldPath.documentId(), "in", templateIds).get();
10664
+ const templatesSnapshot = await this.db.collection(DOCUMENTATION_TEMPLATES_COLLECTION).where(admin15.firestore.FieldPath.documentId(), "in", templateIds).get();
7439
10665
  const templatesMap = /* @__PURE__ */ new Map();
7440
- templatesSnapshot.forEach((doc) => {
7441
- templatesMap.set(doc.id, doc.data());
10666
+ templatesSnapshot.forEach((doc3) => {
10667
+ templatesMap.set(doc3.id, doc3.data());
7442
10668
  });
7443
10669
  for (const templateRef of technologyTemplates) {
7444
10670
  const template = templatesMap.get(templateRef.templateId);
@@ -7501,8 +10727,8 @@ var BookingAdmin = class {
7501
10727
  * Creates a new BookingAdmin instance
7502
10728
  * @param firestore - Firestore instance provided by the caller
7503
10729
  */
7504
- constructor(firestore18) {
7505
- this.db = firestore18 || admin15.firestore();
10730
+ constructor(firestore19) {
10731
+ this.db = firestore19 || admin16.firestore();
7506
10732
  this.documentManagerAdmin = new DocumentManagerAdminService(this.db);
7507
10733
  }
7508
10734
  /**
@@ -7524,8 +10750,8 @@ var BookingAdmin = class {
7524
10750
  timeframeStart: timeframe.start instanceof Date ? timeframe.start.toISOString() : timeframe.start.toDate().toISOString(),
7525
10751
  timeframeEnd: timeframe.end instanceof Date ? timeframe.end.toISOString() : timeframe.end.toDate().toISOString()
7526
10752
  });
7527
- const start = timeframe.start instanceof Date ? admin15.firestore.Timestamp.fromDate(timeframe.start) : timeframe.start;
7528
- const end = timeframe.end instanceof Date ? admin15.firestore.Timestamp.fromDate(timeframe.end) : timeframe.end;
10753
+ const start = timeframe.start instanceof Date ? admin16.firestore.Timestamp.fromDate(timeframe.start) : timeframe.start;
10754
+ const end = timeframe.end instanceof Date ? admin16.firestore.Timestamp.fromDate(timeframe.end) : timeframe.end;
7529
10755
  Logger.debug("[BookingAdmin] Fetching clinic data", { clinicId });
7530
10756
  const clinicDoc = await this.db.collection("clinics").doc(clinicId).get();
7531
10757
  if (!clinicDoc.exists) {
@@ -7611,7 +10837,7 @@ var BookingAdmin = class {
7611
10837
  const result = BookingAvailabilityCalculator.calculateSlots(request);
7612
10838
  const availableSlotsResult = {
7613
10839
  availableSlots: result.availableSlots.map((slot) => ({
7614
- start: admin15.firestore.Timestamp.fromMillis(slot.start.toMillis())
10840
+ start: admin16.firestore.Timestamp.fromMillis(slot.start.toMillis())
7615
10841
  }))
7616
10842
  };
7617
10843
  Logger.info(
@@ -7674,14 +10900,14 @@ var BookingAdmin = class {
7674
10900
  endTime: end.toDate().toISOString()
7675
10901
  });
7676
10902
  const MAX_EVENT_DURATION_MS = 24 * 60 * 60 * 1e3;
7677
- const queryStart = admin15.firestore.Timestamp.fromMillis(
10903
+ const queryStart = admin16.firestore.Timestamp.fromMillis(
7678
10904
  start.toMillis() - MAX_EVENT_DURATION_MS
7679
10905
  );
7680
10906
  const eventsRef = this.db.collection(`clinics/${clinicId}/calendar`).where("eventTime.start", ">=", queryStart).where("eventTime.start", "<", end).orderBy("eventTime.start");
7681
10907
  const snapshot = await eventsRef.get();
7682
- const events = snapshot.docs.map((doc) => ({
7683
- ...doc.data(),
7684
- id: doc.id
10908
+ const events = snapshot.docs.map((doc3) => ({
10909
+ ...doc3.data(),
10910
+ id: doc3.id
7685
10911
  })).filter((event) => {
7686
10912
  return event.eventTime.end.toMillis() > start.toMillis();
7687
10913
  });
@@ -7720,14 +10946,14 @@ var BookingAdmin = class {
7720
10946
  endTime: end.toDate().toISOString()
7721
10947
  });
7722
10948
  const MAX_EVENT_DURATION_MS = 24 * 60 * 60 * 1e3;
7723
- const queryStart = admin15.firestore.Timestamp.fromMillis(
10949
+ const queryStart = admin16.firestore.Timestamp.fromMillis(
7724
10950
  start.toMillis() - MAX_EVENT_DURATION_MS
7725
10951
  );
7726
10952
  const eventsRef = this.db.collection(`practitioners/${practitionerId}/calendar`).where("eventTime.start", ">=", queryStart).where("eventTime.start", "<", end).orderBy("eventTime.start");
7727
10953
  const snapshot = await eventsRef.get();
7728
- const events = snapshot.docs.map((doc) => ({
7729
- ...doc.data(),
7730
- id: doc.id
10954
+ const events = snapshot.docs.map((doc3) => ({
10955
+ ...doc3.data(),
10956
+ id: doc3.id
7731
10957
  })).filter((event) => {
7732
10958
  return event.eventTime.end.toMillis() > start.toMillis();
7733
10959
  });
@@ -7789,8 +11015,8 @@ var BookingAdmin = class {
7789
11015
  `[BookingAdmin] Orchestrating appointment creation for patient ${data.patientId} by user ${authenticatedUserId}`
7790
11016
  );
7791
11017
  const batch = this.db.batch();
7792
- const adminTsNow = admin15.firestore.Timestamp.now();
7793
- const serverTimestampValue = admin15.firestore.FieldValue.serverTimestamp();
11018
+ const adminTsNow = admin16.firestore.Timestamp.now();
11019
+ const serverTimestampValue = admin16.firestore.FieldValue.serverTimestamp();
7794
11020
  try {
7795
11021
  if (!data.patientId || !data.procedureId || !data.appointmentStartTime || !data.appointmentEndTime) {
7796
11022
  return {
@@ -7887,7 +11113,7 @@ var BookingAdmin = class {
7887
11113
  fullName: `${(patientSensitiveData == null ? void 0 : patientSensitiveData.firstName) || ""} ${(patientSensitiveData == null ? void 0 : patientSensitiveData.lastName) || ""}`.trim() || patientProfileData.displayName,
7888
11114
  email: (patientSensitiveData == null ? void 0 : patientSensitiveData.email) || "",
7889
11115
  phone: (patientSensitiveData == null ? void 0 : patientSensitiveData.phoneNumber) || patientProfileData.phoneNumber || null,
7890
- dateOfBirth: (patientSensitiveData == null ? void 0 : patientSensitiveData.dateOfBirth) || patientProfileData.dateOfBirth || admin15.firestore.Timestamp.now(),
11116
+ dateOfBirth: (patientSensitiveData == null ? void 0 : patientSensitiveData.dateOfBirth) || patientProfileData.dateOfBirth || admin16.firestore.Timestamp.now(),
7891
11117
  gender: (patientSensitiveData == null ? void 0 : patientSensitiveData.gender) || "other" /* OTHER */
7892
11118
  };
7893
11119
  const newAppointmentId = this.db.collection(APPOINTMENTS_COLLECTION).doc().id;
@@ -8152,7 +11378,7 @@ var BookingAdmin = class {
8152
11378
  };
8153
11379
 
8154
11380
  // src/admin/free-consultation/free-consultation-utils.admin.ts
8155
- import * as admin16 from "firebase-admin";
11381
+ import * as admin17 from "firebase-admin";
8156
11382
 
8157
11383
  // src/backoffice/types/category.types.ts
8158
11384
  var CATEGORIES_COLLECTION = "backoffice_categories";
@@ -8165,10 +11391,10 @@ var TECHNOLOGIES_COLLECTION = "technologies";
8165
11391
 
8166
11392
  // src/admin/free-consultation/free-consultation-utils.admin.ts
8167
11393
  async function freeConsultationInfrastructure(db) {
8168
- const firestore18 = db || admin16.firestore();
11394
+ const firestore19 = db || admin17.firestore();
8169
11395
  try {
8170
11396
  console.log("[freeConsultationInfrastructure] Checking free consultation infrastructure...");
8171
- const technologyRef = firestore18.collection(TECHNOLOGIES_COLLECTION).doc("free-consultation-tech");
11397
+ const technologyRef = firestore19.collection(TECHNOLOGIES_COLLECTION).doc("free-consultation-tech");
8172
11398
  const technologyDoc = await technologyRef.get();
8173
11399
  if (technologyDoc.exists) {
8174
11400
  console.log(
@@ -8177,7 +11403,7 @@ async function freeConsultationInfrastructure(db) {
8177
11403
  return true;
8178
11404
  }
8179
11405
  console.log("[freeConsultationInfrastructure] Creating free consultation infrastructure...");
8180
- await createFreeConsultationInfrastructure(firestore18);
11406
+ await createFreeConsultationInfrastructure(firestore19);
8181
11407
  console.log(
8182
11408
  "[freeConsultationInfrastructure] Successfully created free consultation infrastructure"
8183
11409
  );
@@ -8374,8 +11600,8 @@ var PractitionerInviteMailingService = class extends BaseMailingService {
8374
11600
  * @param firestore Firestore instance provided by the caller
8375
11601
  * @param mailgunClient Mailgun client instance (mailgun.js v10+) provided by the caller
8376
11602
  */
8377
- constructor(firestore18, mailgunClient) {
8378
- super(firestore18, mailgunClient);
11603
+ constructor(firestore19, mailgunClient) {
11604
+ super(firestore19, mailgunClient);
8379
11605
  this.DEFAULT_REGISTRATION_URL = "https://metaesthetics.net/register";
8380
11606
  this.DEFAULT_SUBJECT = "You've Been Invited to Join as a Practitioner";
8381
11607
  this.DEFAULT_MAILGUN_DOMAIN = "mg.metaesthetics.net";
@@ -9238,8 +12464,8 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
9238
12464
  * @param firestore Firestore instance provided by the caller
9239
12465
  * @param mailgunClient Mailgun client instance (mailgun.js v10+) provided by the caller
9240
12466
  */
9241
- constructor(firestore18, mailgunClient) {
9242
- super(firestore18, mailgunClient);
12467
+ constructor(firestore19, mailgunClient) {
12468
+ super(firestore19, mailgunClient);
9243
12469
  this.DEFAULT_MAILGUN_DOMAIN = "mg.metaesthetics.net";
9244
12470
  this.DEFAULT_FROM_ADDRESS = "MetaEstetics <no-reply@mg.metaesthetics.net>";
9245
12471
  }
@@ -9582,8 +12808,8 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
9582
12808
  */
9583
12809
  async fetchPractitionerById(practitionerId) {
9584
12810
  try {
9585
- const doc = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
9586
- return doc.exists ? doc.data() : null;
12811
+ const doc3 = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
12812
+ return doc3.exists ? doc3.data() : null;
9587
12813
  } catch (error) {
9588
12814
  Logger.error(
9589
12815
  "[ExistingPractitionerInviteMailingService] Error fetching practitioner:",
@@ -9599,8 +12825,8 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
9599
12825
  */
9600
12826
  async fetchClinicById(clinicId) {
9601
12827
  try {
9602
- const doc = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
9603
- return doc.exists ? doc.data() : null;
12828
+ const doc3 = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
12829
+ return doc3.exists ? doc3.data() : null;
9604
12830
  } catch (error) {
9605
12831
  Logger.error(
9606
12832
  "[ExistingPractitionerInviteMailingService] Error fetching clinic:",
@@ -9612,14 +12838,14 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
9612
12838
  };
9613
12839
 
9614
12840
  // src/admin/users/user-profile.admin.ts
9615
- import * as admin17 from "firebase-admin";
12841
+ import * as admin18 from "firebase-admin";
9616
12842
  var UserProfileAdminService = class {
9617
12843
  /**
9618
12844
  * Constructor for UserProfileAdminService
9619
12845
  * @param firestore Optional Firestore instance. If not provided, uses the default admin SDK instance.
9620
12846
  */
9621
- constructor(firestore18) {
9622
- this.db = firestore18 || admin17.firestore();
12847
+ constructor(firestore19) {
12848
+ this.db = firestore19 || admin18.firestore();
9623
12849
  }
9624
12850
  /**
9625
12851
  * Creates a blank user profile with minimal information
@@ -9636,9 +12862,9 @@ var UserProfileAdminService = class {
9636
12862
  roles: [],
9637
12863
  // Empty roles array as requested
9638
12864
  isAnonymous: authUserData.isAnonymous,
9639
- createdAt: admin17.firestore.FieldValue.serverTimestamp(),
9640
- updatedAt: admin17.firestore.FieldValue.serverTimestamp(),
9641
- lastLoginAt: admin17.firestore.FieldValue.serverTimestamp()
12865
+ createdAt: admin18.firestore.FieldValue.serverTimestamp(),
12866
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp(),
12867
+ lastLoginAt: admin18.firestore.FieldValue.serverTimestamp()
9642
12868
  };
9643
12869
  try {
9644
12870
  const userRef = this.db.collection(USERS_COLLECTION).doc(authUserData.uid);
@@ -9714,8 +12940,8 @@ var UserProfileAdminService = class {
9714
12940
  clinics: mergedProfileData.clinics || [],
9715
12941
  doctorIds: mergedProfileData.doctorIds || [],
9716
12942
  clinicIds: mergedProfileData.clinicIds || [],
9717
- createdAt: admin17.firestore.FieldValue.serverTimestamp(),
9718
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
12943
+ createdAt: admin18.firestore.FieldValue.serverTimestamp(),
12944
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp()
9719
12945
  };
9720
12946
  await patientProfileRef.set(patientProfileData);
9721
12947
  patientProfile = {
@@ -9754,8 +12980,8 @@ var UserProfileAdminService = class {
9754
12980
  };
9755
12981
  const sensitiveInfoData = {
9756
12982
  ...mergedSensitiveData,
9757
- createdAt: admin17.firestore.FieldValue.serverTimestamp(),
9758
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
12983
+ createdAt: admin18.firestore.FieldValue.serverTimestamp(),
12984
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp()
9759
12985
  // Leave dateOfBirth as is
9760
12986
  };
9761
12987
  await sensitiveInfoRef.set(sensitiveInfoData);
@@ -9781,7 +13007,7 @@ var UserProfileAdminService = class {
9781
13007
  contraindications: [],
9782
13008
  allergies: [],
9783
13009
  currentMedications: [],
9784
- lastUpdated: admin17.firestore.FieldValue.serverTimestamp(),
13010
+ lastUpdated: admin18.firestore.FieldValue.serverTimestamp(),
9785
13011
  updatedBy: userId
9786
13012
  };
9787
13013
  await medicalInfoRef.set(medicalInfoData);
@@ -9798,14 +13024,14 @@ var UserProfileAdminService = class {
9798
13024
  const batch = this.db.batch();
9799
13025
  if (!userData.roles.includes("patient" /* PATIENT */)) {
9800
13026
  batch.update(userRef, {
9801
- roles: admin17.firestore.FieldValue.arrayUnion("patient" /* PATIENT */),
9802
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
13027
+ roles: admin18.firestore.FieldValue.arrayUnion("patient" /* PATIENT */),
13028
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp()
9803
13029
  });
9804
13030
  }
9805
13031
  if (!userData.patientProfile) {
9806
13032
  batch.update(userRef, {
9807
13033
  patientProfile: patientProfileId,
9808
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
13034
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp()
9809
13035
  });
9810
13036
  }
9811
13037
  await batch.commit();
@@ -9846,8 +13072,8 @@ var UserProfileAdminService = class {
9846
13072
  const userData = userDoc.data();
9847
13073
  if (!userData.roles.includes("clinic_admin" /* CLINIC_ADMIN */)) {
9848
13074
  await userRef.update({
9849
- roles: admin17.firestore.FieldValue.arrayUnion("clinic_admin" /* CLINIC_ADMIN */),
9850
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
13075
+ roles: admin18.firestore.FieldValue.arrayUnion("clinic_admin" /* CLINIC_ADMIN */),
13076
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp()
9851
13077
  });
9852
13078
  }
9853
13079
  const updatedUserDoc = await userRef.get();
@@ -9878,8 +13104,8 @@ var UserProfileAdminService = class {
9878
13104
  const userData = userDoc.data();
9879
13105
  if (!userData.roles.includes("practitioner" /* PRACTITIONER */)) {
9880
13106
  await userRef.update({
9881
- roles: admin17.firestore.FieldValue.arrayUnion("practitioner" /* PRACTITIONER */),
9882
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
13107
+ roles: admin18.firestore.FieldValue.arrayUnion("practitioner" /* PRACTITIONER */),
13108
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp()
9883
13109
  });
9884
13110
  }
9885
13111
  const updatedUserDoc = await userRef.get();
@@ -9898,6 +13124,8 @@ var UserProfileAdminService = class {
9898
13124
  console.log("[Admin Module] Initialized and services exported.");
9899
13125
  TimestampUtils.enableServerMode();
9900
13126
  export {
13127
+ ANALYTICS_COLLECTION,
13128
+ AnalyticsAdminService,
9901
13129
  AppointmentAggregationService,
9902
13130
  AppointmentMailingService,
9903
13131
  AppointmentStatus,
@@ -9905,19 +13133,26 @@ export {
9905
13133
  BillingTransactionType,
9906
13134
  BookingAdmin,
9907
13135
  BookingAvailabilityCalculator,
13136
+ CANCELLATION_ANALYTICS_SUBCOLLECTION,
13137
+ CLINICS_COLLECTION,
13138
+ CLINIC_ANALYTICS_SUBCOLLECTION,
9908
13139
  CalendarAdminService,
9909
13140
  ClinicAggregationService,
13141
+ DASHBOARD_ANALYTICS_SUBCOLLECTION,
9910
13142
  DocumentManagerAdminService,
9911
13143
  ExistingPractitionerInviteMailingService,
9912
13144
  FilledFormsAggregationService,
9913
13145
  Logger,
9914
13146
  NOTIFICATIONS_COLLECTION,
13147
+ NO_SHOW_ANALYTICS_SUBCOLLECTION,
9915
13148
  NotificationStatus,
9916
13149
  NotificationType,
9917
13150
  NotificationsAdmin,
9918
13151
  PATIENTS_COLLECTION,
9919
13152
  PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME,
9920
13153
  PATIENT_SENSITIVE_INFO_COLLECTION,
13154
+ PRACTITIONER_ANALYTICS_SUBCOLLECTION,
13155
+ PROCEDURE_ANALYTICS_SUBCOLLECTION,
9921
13156
  PatientAggregationService,
9922
13157
  PatientInstructionStatus,
9923
13158
  PatientRequirementOverallStatus,
@@ -9928,8 +13163,10 @@ export {
9928
13163
  PractitionerInviteStatus,
9929
13164
  PractitionerTokenStatus,
9930
13165
  ProcedureAggregationService,
13166
+ REVENUE_ANALYTICS_SUBCOLLECTION,
9931
13167
  ReviewsAggregationService,
9932
13168
  SubscriptionStatus,
13169
+ TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION,
9933
13170
  UserProfileAdminService,
9934
13171
  UserRole,
9935
13172
  freeConsultationInfrastructure