@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
@@ -218,6 +218,8 @@ var require_compat = __commonJS({
218
218
  // src/admin/index.ts
219
219
  var index_exports = {};
220
220
  __export(index_exports, {
221
+ ANALYTICS_COLLECTION: () => ANALYTICS_COLLECTION,
222
+ AnalyticsAdminService: () => AnalyticsAdminService,
221
223
  AppointmentAggregationService: () => AppointmentAggregationService,
222
224
  AppointmentMailingService: () => AppointmentMailingService,
223
225
  AppointmentStatus: () => AppointmentStatus,
@@ -225,19 +227,26 @@ __export(index_exports, {
225
227
  BillingTransactionType: () => BillingTransactionType,
226
228
  BookingAdmin: () => BookingAdmin,
227
229
  BookingAvailabilityCalculator: () => BookingAvailabilityCalculator,
230
+ CANCELLATION_ANALYTICS_SUBCOLLECTION: () => CANCELLATION_ANALYTICS_SUBCOLLECTION,
231
+ CLINICS_COLLECTION: () => CLINICS_COLLECTION,
232
+ CLINIC_ANALYTICS_SUBCOLLECTION: () => CLINIC_ANALYTICS_SUBCOLLECTION,
228
233
  CalendarAdminService: () => CalendarAdminService,
229
234
  ClinicAggregationService: () => ClinicAggregationService,
235
+ DASHBOARD_ANALYTICS_SUBCOLLECTION: () => DASHBOARD_ANALYTICS_SUBCOLLECTION,
230
236
  DocumentManagerAdminService: () => DocumentManagerAdminService,
231
237
  ExistingPractitionerInviteMailingService: () => ExistingPractitionerInviteMailingService,
232
238
  FilledFormsAggregationService: () => FilledFormsAggregationService,
233
239
  Logger: () => Logger,
234
240
  NOTIFICATIONS_COLLECTION: () => NOTIFICATIONS_COLLECTION,
241
+ NO_SHOW_ANALYTICS_SUBCOLLECTION: () => NO_SHOW_ANALYTICS_SUBCOLLECTION,
235
242
  NotificationStatus: () => NotificationStatus,
236
243
  NotificationType: () => NotificationType,
237
244
  NotificationsAdmin: () => NotificationsAdmin,
238
245
  PATIENTS_COLLECTION: () => PATIENTS_COLLECTION,
239
246
  PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME: () => PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME,
240
247
  PATIENT_SENSITIVE_INFO_COLLECTION: () => PATIENT_SENSITIVE_INFO_COLLECTION,
248
+ PRACTITIONER_ANALYTICS_SUBCOLLECTION: () => PRACTITIONER_ANALYTICS_SUBCOLLECTION,
249
+ PROCEDURE_ANALYTICS_SUBCOLLECTION: () => PROCEDURE_ANALYTICS_SUBCOLLECTION,
241
250
  PatientAggregationService: () => PatientAggregationService,
242
251
  PatientInstructionStatus: () => PatientInstructionStatus,
243
252
  PatientRequirementOverallStatus: () => PatientRequirementOverallStatus,
@@ -248,8 +257,10 @@ __export(index_exports, {
248
257
  PractitionerInviteStatus: () => PractitionerInviteStatus,
249
258
  PractitionerTokenStatus: () => PractitionerTokenStatus,
250
259
  ProcedureAggregationService: () => ProcedureAggregationService,
260
+ REVENUE_ANALYTICS_SUBCOLLECTION: () => REVENUE_ANALYTICS_SUBCOLLECTION,
251
261
  ReviewsAggregationService: () => ReviewsAggregationService,
252
262
  SubscriptionStatus: () => SubscriptionStatus,
263
+ TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION: () => TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION,
253
264
  UserProfileAdminService: () => UserProfileAdminService,
254
265
  UserRole: () => UserRole,
255
266
  freeConsultationInfrastructure: () => freeConsultationInfrastructure
@@ -543,6 +554,14 @@ var AppointmentStatus = /* @__PURE__ */ ((AppointmentStatus2) => {
543
554
  AppointmentStatus2["RESCHEDULED_BY_CLINIC"] = "rescheduled_by_clinic";
544
555
  return AppointmentStatus2;
545
556
  })(AppointmentStatus || {});
557
+ var PaymentStatus = /* @__PURE__ */ ((PaymentStatus2) => {
558
+ PaymentStatus2["UNPAID"] = "unpaid";
559
+ PaymentStatus2["PAID"] = "paid";
560
+ PaymentStatus2["PARTIALLY_PAID"] = "partially_paid";
561
+ PaymentStatus2["REFUNDED"] = "refunded";
562
+ PaymentStatus2["NOT_APPLICABLE"] = "not_applicable";
563
+ return PaymentStatus2;
564
+ })(PaymentStatus || {});
546
565
  var APPOINTMENTS_COLLECTION = "appointments";
547
566
 
548
567
  // src/types/patient/patient-requirements.ts
@@ -587,6 +606,17 @@ var DOCTOR_FORMS_SUBCOLLECTION = "doctor-forms";
587
606
  // src/types/reviews/index.ts
588
607
  var REVIEWS_COLLECTION = "reviews";
589
608
 
609
+ // src/types/analytics/stored-analytics.types.ts
610
+ var ANALYTICS_COLLECTION = "analytics";
611
+ var PRACTITIONER_ANALYTICS_SUBCOLLECTION = "practitioners";
612
+ var PROCEDURE_ANALYTICS_SUBCOLLECTION = "procedures";
613
+ var CLINIC_ANALYTICS_SUBCOLLECTION = "clinic";
614
+ var DASHBOARD_ANALYTICS_SUBCOLLECTION = "dashboard";
615
+ var TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION = "time_efficiency";
616
+ var CANCELLATION_ANALYTICS_SUBCOLLECTION = "cancellations";
617
+ var NO_SHOW_ANALYTICS_SUBCOLLECTION = "no_shows";
618
+ var REVENUE_ANALYTICS_SUBCOLLECTION = "revenue";
619
+
590
620
  // src/admin/notifications/notifications.admin.ts
591
621
  var admin2 = __toESM(require("firebase-admin"));
592
622
  var import_expo_server_sdk = require("expo-server-sdk");
@@ -651,16 +681,16 @@ var Logger = class {
651
681
 
652
682
  // src/admin/notifications/notifications.admin.ts
653
683
  var NotificationsAdmin = class {
654
- constructor(firestore18) {
684
+ constructor(firestore19) {
655
685
  this.expo = new import_expo_server_sdk.Expo();
656
- this.db = firestore18 || admin2.firestore();
686
+ this.db = firestore19 || admin2.firestore();
657
687
  }
658
688
  /**
659
689
  * Dohvata notifikaciju po ID-u
660
690
  */
661
691
  async getNotification(id) {
662
- const doc = await this.db.collection("notifications").doc(id).get();
663
- return doc.exists ? { id: doc.id, ...doc.data() } : null;
692
+ const doc3 = await this.db.collection("notifications").doc(id).get();
693
+ return doc3.exists ? { id: doc3.id, ...doc3.data() } : null;
664
694
  }
665
695
  /**
666
696
  * Kreira novu notifikaciju
@@ -847,10 +877,10 @@ var NotificationsAdmin = class {
847
877
  return;
848
878
  }
849
879
  const results = await Promise.allSettled(
850
- pendingNotifications.docs.map(async (doc) => {
880
+ pendingNotifications.docs.map(async (doc3) => {
851
881
  const notification = {
852
- id: doc.id,
853
- ...doc.data()
882
+ id: doc3.id,
883
+ ...doc3.data()
854
884
  };
855
885
  Logger.info(
856
886
  `[NotificationsAdmin] Processing notification ${notification.id} of type ${notification.notificationType}`
@@ -891,8 +921,8 @@ var NotificationsAdmin = class {
891
921
  break;
892
922
  }
893
923
  const batch = this.db.batch();
894
- oldNotifications.docs.forEach((doc) => {
895
- batch.delete(doc.ref);
924
+ oldNotifications.docs.forEach((doc3) => {
925
+ batch.delete(doc3.ref);
896
926
  });
897
927
  await batch.commit();
898
928
  totalDeleted += oldNotifications.size;
@@ -1162,8 +1192,8 @@ var NotificationsAdmin = class {
1162
1192
 
1163
1193
  // src/admin/requirements/patient-requirements.admin.service.ts
1164
1194
  var PatientRequirementsAdminService = class {
1165
- constructor(firestore18) {
1166
- this.db = firestore18 || admin3.firestore();
1195
+ constructor(firestore19) {
1196
+ this.db = firestore19 || admin3.firestore();
1167
1197
  this.notificationsAdmin = new NotificationsAdmin(this.db);
1168
1198
  }
1169
1199
  /**
@@ -1491,8 +1521,8 @@ var PatientRequirementsAdminService = class {
1491
1521
  // src/admin/calendar/calendar.admin.service.ts
1492
1522
  var admin4 = __toESM(require("firebase-admin"));
1493
1523
  var CalendarAdminService = class {
1494
- constructor(firestore18) {
1495
- this.db = firestore18 || admin4.firestore();
1524
+ constructor(firestore19) {
1525
+ this.db = firestore19 || admin4.firestore();
1496
1526
  Logger.info("[CalendarAdminService] Initialized.");
1497
1527
  }
1498
1528
  /**
@@ -1778,9 +1808,9 @@ var BaseMailingService = class {
1778
1808
  * @param firestore Firestore instance provided by the caller
1779
1809
  * @param mailgunClient Mailgun client instance (mailgun.js v10+) provided by the caller
1780
1810
  */
1781
- constructor(firestore18, mailgunClient) {
1811
+ constructor(firestore19, mailgunClient) {
1782
1812
  var _a;
1783
- this.db = firestore18;
1813
+ this.db = firestore19;
1784
1814
  this.mailgunClient = mailgunClient;
1785
1815
  if (!this.db) {
1786
1816
  Logger.error("[BaseMailingService] No Firestore instance provided");
@@ -2322,8 +2352,8 @@ var clinicAppointmentRequestedTemplate = `
2322
2352
  </html>
2323
2353
  `;
2324
2354
  var AppointmentMailingService = class extends BaseMailingService {
2325
- constructor(firestore18, mailgunClient) {
2326
- super(firestore18, mailgunClient);
2355
+ constructor(firestore19, mailgunClient) {
2356
+ super(firestore19, mailgunClient);
2327
2357
  this.DEFAULT_MAILGUN_DOMAIN = "mg.metaesthetics.net";
2328
2358
  Logger.info("[AppointmentMailingService] Initialized.");
2329
2359
  }
@@ -2552,8 +2582,8 @@ var AppointmentAggregationService = class {
2552
2582
  * @param mailgunClient - An initialized Mailgun client instance.
2553
2583
  * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
2554
2584
  */
2555
- constructor(mailgunClient, firestore18) {
2556
- this.db = firestore18 || admin6.firestore();
2585
+ constructor(mailgunClient, firestore19) {
2586
+ this.db = firestore19 || admin6.firestore();
2557
2587
  this.appointmentMailingService = new AppointmentMailingService(
2558
2588
  this.db,
2559
2589
  mailgunClient
@@ -3580,10 +3610,10 @@ var AppointmentAggregationService = class {
3580
3610
  }
3581
3611
  const batch = this.db.batch();
3582
3612
  let instancesUpdatedCount = 0;
3583
- instancesSnapshot.docs.forEach((doc) => {
3584
- const instance = doc.data();
3613
+ instancesSnapshot.docs.forEach((doc3) => {
3614
+ const instance = doc3.data();
3585
3615
  if (instance.overallStatus !== newOverallStatus && instance.overallStatus !== "failedToProcess" /* FAILED_TO_PROCESS */) {
3586
- batch.update(doc.ref, {
3616
+ batch.update(doc3.ref, {
3587
3617
  overallStatus: newOverallStatus,
3588
3618
  updatedAt: admin6.firestore.FieldValue.serverTimestamp()
3589
3619
  // Cast for now
@@ -3592,7 +3622,7 @@ var AppointmentAggregationService = class {
3592
3622
  });
3593
3623
  instancesUpdatedCount++;
3594
3624
  Logger.debug(
3595
- `[AggService] Added update for PatientRequirementInstance ${doc.id} to batch. New status: ${newOverallStatus}`
3625
+ `[AggService] Added update for PatientRequirementInstance ${doc3.id} to batch. New status: ${newOverallStatus}`
3596
3626
  );
3597
3627
  }
3598
3628
  });
@@ -3767,8 +3797,8 @@ var AppointmentAggregationService = class {
3767
3797
  // --- Data Fetching Helpers (Consider moving to a data access layer or using existing services if available) ---
3768
3798
  async fetchPatientProfile(patientId) {
3769
3799
  try {
3770
- const doc = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).get();
3771
- return doc.exists ? doc.data() : null;
3800
+ const doc3 = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).get();
3801
+ return doc3.exists ? doc3.data() : null;
3772
3802
  } catch (error) {
3773
3803
  Logger.error(`[AggService] Error fetching patient profile ${patientId}:`, error);
3774
3804
  return null;
@@ -3781,12 +3811,12 @@ var AppointmentAggregationService = class {
3781
3811
  */
3782
3812
  async fetchPatientSensitiveInfo(patientId) {
3783
3813
  try {
3784
- const doc = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).collection(PATIENT_SENSITIVE_INFO_COLLECTION).doc(patientId).get();
3785
- if (!doc.exists) {
3814
+ const doc3 = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).collection(PATIENT_SENSITIVE_INFO_COLLECTION).doc(patientId).get();
3815
+ if (!doc3.exists) {
3786
3816
  Logger.warn(`[AggService] No sensitive info found for patient ${patientId}`);
3787
3817
  return null;
3788
3818
  }
3789
- return doc.data();
3819
+ return doc3.data();
3790
3820
  } catch (error) {
3791
3821
  Logger.error(`[AggService] Error fetching patient sensitive info ${patientId}:`, error);
3792
3822
  return null;
@@ -3803,12 +3833,12 @@ var AppointmentAggregationService = class {
3803
3833
  return null;
3804
3834
  }
3805
3835
  try {
3806
- const doc = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
3807
- if (!doc.exists) {
3836
+ const doc3 = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
3837
+ if (!doc3.exists) {
3808
3838
  Logger.warn(`[AggService] No practitioner profile found for ID ${practitionerId}`);
3809
3839
  return null;
3810
3840
  }
3811
- return doc.data();
3841
+ return doc3.data();
3812
3842
  } catch (error) {
3813
3843
  Logger.error(`[AggService] Error fetching practitioner profile ${practitionerId}:`, error);
3814
3844
  return null;
@@ -3825,12 +3855,12 @@ var AppointmentAggregationService = class {
3825
3855
  return null;
3826
3856
  }
3827
3857
  try {
3828
- const doc = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
3829
- if (!doc.exists) {
3858
+ const doc3 = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
3859
+ if (!doc3.exists) {
3830
3860
  Logger.warn(`[AggService] No clinic info found for ID ${clinicId}`);
3831
3861
  return null;
3832
3862
  }
3833
- return doc.data();
3863
+ return doc3.data();
3834
3864
  } catch (error) {
3835
3865
  Logger.error(`[AggService] Error fetching clinic info ${clinicId}:`, error);
3836
3866
  return null;
@@ -4074,8 +4104,8 @@ var ClinicAggregationService = class {
4074
4104
  * Constructor for ClinicAggregationService.
4075
4105
  * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
4076
4106
  */
4077
- constructor(firestore18) {
4078
- this.db = firestore18 || admin7.firestore();
4107
+ constructor(firestore19) {
4108
+ this.db = firestore19 || admin7.firestore();
4079
4109
  }
4080
4110
  /**
4081
4111
  * Adds clinic information to a clinic group when a new clinic is created
@@ -4297,11 +4327,11 @@ var ClinicAggregationService = class {
4297
4327
  return;
4298
4328
  }
4299
4329
  const batch = this.db.batch();
4300
- snapshot.docs.forEach((doc) => {
4330
+ snapshot.docs.forEach((doc3) => {
4301
4331
  console.log(
4302
- `[ClinicAggregationService] Updating location for calendar event ${doc.ref.path}`
4332
+ `[ClinicAggregationService] Updating location for calendar event ${doc3.ref.path}`
4303
4333
  );
4304
- batch.update(doc.ref, {
4334
+ batch.update(doc3.ref, {
4305
4335
  eventLocation: newLocation,
4306
4336
  updatedAt: admin7.firestore.FieldValue.serverTimestamp()
4307
4337
  });
@@ -4344,11 +4374,11 @@ var ClinicAggregationService = class {
4344
4374
  return;
4345
4375
  }
4346
4376
  const batch = this.db.batch();
4347
- snapshot.docs.forEach((doc) => {
4377
+ snapshot.docs.forEach((doc3) => {
4348
4378
  console.log(
4349
- `[ClinicAggregationService] Updating clinic info for calendar event ${doc.ref.path}`
4379
+ `[ClinicAggregationService] Updating clinic info for calendar event ${doc3.ref.path}`
4350
4380
  );
4351
- batch.update(doc.ref, {
4381
+ batch.update(doc3.ref, {
4352
4382
  clinicInfo,
4353
4383
  updatedAt: admin7.firestore.FieldValue.serverTimestamp()
4354
4384
  });
@@ -4559,11 +4589,11 @@ var ClinicAggregationService = class {
4559
4589
  return;
4560
4590
  }
4561
4591
  const batch = this.db.batch();
4562
- snapshot.docs.forEach((doc) => {
4592
+ snapshot.docs.forEach((doc3) => {
4563
4593
  console.log(
4564
- `[ClinicAggregationService] Canceling calendar event ${doc.ref.path}`
4594
+ `[ClinicAggregationService] Canceling calendar event ${doc3.ref.path}`
4565
4595
  );
4566
- batch.update(doc.ref, {
4596
+ batch.update(doc3.ref, {
4567
4597
  status: "CANCELED",
4568
4598
  cancelReason: "Clinic deleted",
4569
4599
  updatedAt: admin7.firestore.FieldValue.serverTimestamp()
@@ -4590,8 +4620,8 @@ var FilledFormsAggregationService = class {
4590
4620
  * Constructor for FilledFormsAggregationService.
4591
4621
  * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
4592
4622
  */
4593
- constructor(firestore18) {
4594
- this.db = firestore18 || admin8.firestore();
4623
+ constructor(firestore19) {
4624
+ this.db = firestore19 || admin8.firestore();
4595
4625
  Logger.info("[FilledFormsAggregationService] Initialized");
4596
4626
  }
4597
4627
  /**
@@ -4798,8 +4828,8 @@ var FilledFormsAggregationService = class {
4798
4828
  var admin9 = __toESM(require("firebase-admin"));
4799
4829
  var CALENDAR_SUBCOLLECTION_ID2 = "calendar";
4800
4830
  var PatientAggregationService = class {
4801
- constructor(firestore18) {
4802
- this.db = firestore18 || admin9.firestore();
4831
+ constructor(firestore19) {
4832
+ this.db = firestore19 || admin9.firestore();
4803
4833
  }
4804
4834
  // --- Methods for Patient Creation --- >
4805
4835
  // No specific aggregations defined for patient creation in the plan.
@@ -4831,11 +4861,11 @@ var PatientAggregationService = class {
4831
4861
  return;
4832
4862
  }
4833
4863
  const batch = this.db.batch();
4834
- snapshot.docs.forEach((doc) => {
4864
+ snapshot.docs.forEach((doc3) => {
4835
4865
  console.log(
4836
- `[PatientAggregationService] Updating patient info for calendar event ${doc.ref.path}`
4866
+ `[PatientAggregationService] Updating patient info for calendar event ${doc3.ref.path}`
4837
4867
  );
4838
- batch.update(doc.ref, {
4868
+ batch.update(doc3.ref, {
4839
4869
  patientInfo,
4840
4870
  updatedAt: admin9.firestore.FieldValue.serverTimestamp()
4841
4871
  });
@@ -4879,11 +4909,11 @@ var PatientAggregationService = class {
4879
4909
  return;
4880
4910
  }
4881
4911
  const batch = this.db.batch();
4882
- snapshot.docs.forEach((doc) => {
4912
+ snapshot.docs.forEach((doc3) => {
4883
4913
  console.log(
4884
- `[PatientAggregationService] Canceling calendar event ${doc.ref.path}`
4914
+ `[PatientAggregationService] Canceling calendar event ${doc3.ref.path}`
4885
4915
  );
4886
- batch.update(doc.ref, {
4916
+ batch.update(doc3.ref, {
4887
4917
  status: "CANCELED",
4888
4918
  cancelReason: "Patient deleted",
4889
4919
  updatedAt: admin9.firestore.FieldValue.serverTimestamp()
@@ -4907,8 +4937,8 @@ var PatientAggregationService = class {
4907
4937
  var admin10 = __toESM(require("firebase-admin"));
4908
4938
  var CALENDAR_SUBCOLLECTION_ID3 = "calendar";
4909
4939
  var PractitionerAggregationService = class {
4910
- constructor(firestore18) {
4911
- this.db = firestore18 || admin10.firestore();
4940
+ constructor(firestore19) {
4941
+ this.db = firestore19 || admin10.firestore();
4912
4942
  }
4913
4943
  /**
4914
4944
  * Adds practitioner information to a clinic when a new practitioner is created
@@ -5054,11 +5084,11 @@ var PractitionerAggregationService = class {
5054
5084
  return;
5055
5085
  }
5056
5086
  const batch = this.db.batch();
5057
- snapshot.docs.forEach((doc) => {
5087
+ snapshot.docs.forEach((doc3) => {
5058
5088
  console.log(
5059
- `[PractitionerAggregationService] Updating practitioner info for calendar event ${doc.ref.path}`
5089
+ `[PractitionerAggregationService] Updating practitioner info for calendar event ${doc3.ref.path}`
5060
5090
  );
5061
- batch.update(doc.ref, {
5091
+ batch.update(doc3.ref, {
5062
5092
  practitionerInfo,
5063
5093
  updatedAt: admin10.firestore.FieldValue.serverTimestamp()
5064
5094
  });
@@ -5142,11 +5172,11 @@ var PractitionerAggregationService = class {
5142
5172
  return;
5143
5173
  }
5144
5174
  const batch = this.db.batch();
5145
- snapshot.docs.forEach((doc) => {
5175
+ snapshot.docs.forEach((doc3) => {
5146
5176
  console.log(
5147
- `[PractitionerAggregationService] Canceling calendar event ${doc.ref.path}`
5177
+ `[PractitionerAggregationService] Canceling calendar event ${doc3.ref.path}`
5148
5178
  );
5149
- batch.update(doc.ref, {
5179
+ batch.update(doc3.ref, {
5150
5180
  status: "CANCELED",
5151
5181
  cancelReason: "Practitioner deleted",
5152
5182
  updatedAt: admin10.firestore.FieldValue.serverTimestamp()
@@ -5247,8 +5277,8 @@ var PractitionerInviteAggregationService = class {
5247
5277
  * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
5248
5278
  * @param mailingService Optional mailing service for sending emails
5249
5279
  */
5250
- constructor(firestore18, mailingService) {
5251
- this.db = firestore18 || admin11.firestore();
5280
+ constructor(firestore19, mailingService) {
5281
+ this.db = firestore19 || admin11.firestore();
5252
5282
  this.mailingService = mailingService;
5253
5283
  Logger.info("[PractitionerInviteAggregationService] Initialized.");
5254
5284
  }
@@ -5698,8 +5728,8 @@ var PractitionerInviteAggregationService = class {
5698
5728
  */
5699
5729
  async fetchClinicAdminById(adminId) {
5700
5730
  try {
5701
- const doc = await this.db.collection(CLINIC_ADMINS_COLLECTION).doc(adminId).get();
5702
- return doc.exists ? doc.data() : null;
5731
+ const doc3 = await this.db.collection(CLINIC_ADMINS_COLLECTION).doc(adminId).get();
5732
+ return doc3.exists ? doc3.data() : null;
5703
5733
  } catch (error) {
5704
5734
  Logger.error(
5705
5735
  `[PractitionerInviteAggService] Error fetching clinic admin ${adminId}:`,
@@ -5715,8 +5745,8 @@ var PractitionerInviteAggregationService = class {
5715
5745
  */
5716
5746
  async fetchPractitionerById(practitionerId) {
5717
5747
  try {
5718
- const doc = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
5719
- return doc.exists ? doc.data() : null;
5748
+ const doc3 = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
5749
+ return doc3.exists ? doc3.data() : null;
5720
5750
  } catch (error) {
5721
5751
  Logger.error(
5722
5752
  `[PractitionerInviteAggService] Error fetching practitioner ${practitionerId}:`,
@@ -5732,8 +5762,8 @@ var PractitionerInviteAggregationService = class {
5732
5762
  */
5733
5763
  async fetchClinicById(clinicId) {
5734
5764
  try {
5735
- const doc = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
5736
- return doc.exists ? doc.data() : null;
5765
+ const doc3 = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
5766
+ return doc3.exists ? doc3.data() : null;
5737
5767
  } catch (error) {
5738
5768
  Logger.error(
5739
5769
  `[PractitionerInviteAggService] Error fetching clinic ${clinicId}:`,
@@ -5754,8 +5784,8 @@ var PractitionerInviteAggregationService = class {
5754
5784
  var _a, _b, _c, _d, _e, _f;
5755
5785
  if (!this.mailingService) return;
5756
5786
  try {
5757
- const admin18 = await this.fetchClinicAdminById(invite.invitedBy);
5758
- if (!admin18) {
5787
+ const admin19 = await this.fetchClinicAdminById(invite.invitedBy);
5788
+ if (!admin19) {
5759
5789
  Logger.warn(
5760
5790
  `[PractitionerInviteAggService] Admin ${invite.invitedBy} not found, using clinic contact email as fallback`
5761
5791
  );
@@ -5793,7 +5823,7 @@ var PractitionerInviteAggregationService = class {
5793
5823
  );
5794
5824
  return;
5795
5825
  }
5796
- const adminName = `${admin18.contactInfo.firstName} ${admin18.contactInfo.lastName}`;
5826
+ const adminName = `${admin19.contactInfo.firstName} ${admin19.contactInfo.lastName}`;
5797
5827
  const notificationData = {
5798
5828
  invite,
5799
5829
  practitioner: {
@@ -5809,7 +5839,7 @@ var PractitionerInviteAggregationService = class {
5809
5839
  clinic: {
5810
5840
  name: clinic.name,
5811
5841
  adminName,
5812
- adminEmail: admin18.contactInfo.email
5842
+ adminEmail: admin19.contactInfo.email
5813
5843
  // Use the specific admin's email
5814
5844
  },
5815
5845
  context: {
@@ -5845,8 +5875,8 @@ var PractitionerInviteAggregationService = class {
5845
5875
  var _a, _b, _c, _d, _e, _f;
5846
5876
  if (!this.mailingService) return;
5847
5877
  try {
5848
- const admin18 = await this.fetchClinicAdminById(invite.invitedBy);
5849
- if (!admin18) {
5878
+ const admin19 = await this.fetchClinicAdminById(invite.invitedBy);
5879
+ if (!admin19) {
5850
5880
  Logger.warn(
5851
5881
  `[PractitionerInviteAggService] Admin ${invite.invitedBy} not found, using clinic contact email as fallback`
5852
5882
  );
@@ -5884,7 +5914,7 @@ var PractitionerInviteAggregationService = class {
5884
5914
  );
5885
5915
  return;
5886
5916
  }
5887
- const adminName = `${admin18.contactInfo.firstName} ${admin18.contactInfo.lastName}`;
5917
+ const adminName = `${admin19.contactInfo.firstName} ${admin19.contactInfo.lastName}`;
5888
5918
  const notificationData = {
5889
5919
  invite,
5890
5920
  practitioner: {
@@ -5898,7 +5928,7 @@ var PractitionerInviteAggregationService = class {
5898
5928
  clinic: {
5899
5929
  name: clinic.name,
5900
5930
  adminName,
5901
- adminEmail: admin18.contactInfo.email
5931
+ adminEmail: admin19.contactInfo.email
5902
5932
  // Use the specific admin's email
5903
5933
  },
5904
5934
  context: {
@@ -5930,8 +5960,8 @@ var PractitionerInviteAggregationService = class {
5930
5960
  var admin12 = __toESM(require("firebase-admin"));
5931
5961
  var CALENDAR_SUBCOLLECTION_ID4 = "calendar";
5932
5962
  var ProcedureAggregationService = class {
5933
- constructor(firestore18) {
5934
- this.db = firestore18 || admin12.firestore();
5963
+ constructor(firestore19) {
5964
+ this.db = firestore19 || admin12.firestore();
5935
5965
  }
5936
5966
  /**
5937
5967
  * Adds procedure information to a practitioner when a new procedure is created
@@ -6168,11 +6198,11 @@ var ProcedureAggregationService = class {
6168
6198
  return;
6169
6199
  }
6170
6200
  const batch = this.db.batch();
6171
- snapshot.docs.forEach((doc) => {
6201
+ snapshot.docs.forEach((doc3) => {
6172
6202
  console.log(
6173
- `[ProcedureAggregationService] Updating procedure info for calendar event ${doc.ref.path}`
6203
+ `[ProcedureAggregationService] Updating procedure info for calendar event ${doc3.ref.path}`
6174
6204
  );
6175
- batch.update(doc.ref, {
6205
+ batch.update(doc3.ref, {
6176
6206
  procedureInfo,
6177
6207
  updatedAt: admin12.firestore.FieldValue.serverTimestamp()
6178
6208
  });
@@ -6215,11 +6245,11 @@ var ProcedureAggregationService = class {
6215
6245
  return;
6216
6246
  }
6217
6247
  const batch = this.db.batch();
6218
- snapshot.docs.forEach((doc) => {
6248
+ snapshot.docs.forEach((doc3) => {
6219
6249
  console.log(
6220
- `[ProcedureAggregationService] Canceling calendar event ${doc.ref.path}`
6250
+ `[ProcedureAggregationService] Canceling calendar event ${doc3.ref.path}`
6221
6251
  );
6222
- batch.update(doc.ref, {
6252
+ batch.update(doc3.ref, {
6223
6253
  status: "CANCELED",
6224
6254
  cancelReason: "Procedure deleted or inactivated",
6225
6255
  updatedAt: admin12.firestore.FieldValue.serverTimestamp()
@@ -6449,8 +6479,8 @@ var ReviewsAggregationService = class {
6449
6479
  * Constructor for ReviewsAggregationService.
6450
6480
  * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
6451
6481
  */
6452
- constructor(firestore18) {
6453
- this.db = firestore18 || admin13.firestore();
6482
+ constructor(firestore19) {
6483
+ this.db = firestore19 || admin13.firestore();
6454
6484
  }
6455
6485
  /**
6456
6486
  * Process a newly created review and update all related entities
@@ -6600,7 +6630,7 @@ var ReviewsAggregationService = class {
6600
6630
  );
6601
6631
  return updatedReviewInfo2;
6602
6632
  }
6603
- const reviews = reviewsQuery.docs.map((doc) => doc.data());
6633
+ const reviews = reviewsQuery.docs.map((doc3) => doc3.data());
6604
6634
  const clinicReviews = reviews.map((review) => review.clinicReview).filter((review) => review !== void 0);
6605
6635
  let totalRating = 0;
6606
6636
  let totalCleanliness = 0;
@@ -6690,7 +6720,7 @@ var ReviewsAggregationService = class {
6690
6720
  );
6691
6721
  return updatedReviewInfo2;
6692
6722
  }
6693
- const reviews = reviewsQuery.docs.map((doc) => doc.data());
6723
+ const reviews = reviewsQuery.docs.map((doc3) => doc3.data());
6694
6724
  const practitionerReviews = reviews.map((review) => review.practitionerReview).filter((review) => review !== void 0);
6695
6725
  let totalRating = 0;
6696
6726
  let totalKnowledgeAndExpertise = 0;
@@ -6763,7 +6793,7 @@ var ReviewsAggregationService = class {
6763
6793
  recommendationPercentage: 0
6764
6794
  };
6765
6795
  const allReviewsQuery = await this.db.collection(REVIEWS_COLLECTION).get();
6766
- const reviews = allReviewsQuery.docs.map((doc) => doc.data());
6796
+ const reviews = allReviewsQuery.docs.map((doc3) => doc3.data());
6767
6797
  const procedureReviews = [];
6768
6798
  reviews.forEach((review) => {
6769
6799
  if (review.procedureReview && review.procedureReview.procedureId === procedureId) {
@@ -6929,225 +6959,3432 @@ var ReviewsAggregationService = class {
6929
6959
  }
6930
6960
  };
6931
6961
 
6932
- // src/admin/booking/booking.calculator.ts
6933
- var import_firestore2 = require("firebase/firestore");
6934
- var import_luxon2 = require("luxon");
6935
- var BookingAvailabilityCalculator = class {
6936
- /**
6937
- * Calculate available booking slots based on the provided data
6938
- *
6939
- * @param request - The request containing all necessary data for calculation
6940
- * @returns Response with available booking slots
6941
- */
6942
- static calculateSlots(request) {
6943
- const {
6944
- clinic,
6945
- practitioner,
6946
- procedure,
6947
- timeframe,
6948
- clinicCalendarEvents,
6949
- practitionerCalendarEvents,
6950
- tz
6951
- } = request;
6952
- const schedulingIntervalMinutes = clinic.schedulingInterval || this.DEFAULT_INTERVAL_MINUTES;
6953
- const procedureDurationMinutes = procedure.duration;
6954
- console.log(
6955
- `Calculating slots with interval: ${schedulingIntervalMinutes}min and procedure duration: ${procedureDurationMinutes}min`
6956
- );
6957
- let availableIntervals = [
6958
- { start: timeframe.start, end: timeframe.end }
6959
- ];
6960
- availableIntervals = this.applyClinicWorkingHours(
6961
- availableIntervals,
6962
- clinic.workingHours,
6963
- timeframe,
6964
- tz
6965
- );
6966
- availableIntervals = this.subtractBlockingEvents(
6967
- availableIntervals,
6968
- clinicCalendarEvents
6969
- );
6970
- availableIntervals = this.applyPractitionerWorkingHours(
6971
- availableIntervals,
6972
- practitioner,
6973
- clinic.id,
6974
- timeframe,
6975
- tz
6976
- );
6977
- availableIntervals = this.subtractPractitionerBusyTimes(
6978
- availableIntervals,
6979
- practitionerCalendarEvents
6980
- );
6981
- console.log(
6982
- `After all filters, have ${availableIntervals.length} available intervals`
6983
- );
6984
- const availableSlots = this.generateAvailableSlots(
6985
- availableIntervals,
6986
- schedulingIntervalMinutes,
6987
- procedureDurationMinutes,
6988
- tz
6989
- );
6990
- return { availableSlots };
6962
+ // src/admin/analytics/analytics.admin.service.ts
6963
+ var admin14 = __toESM(require("firebase-admin"));
6964
+
6965
+ // src/services/analytics/analytics.service.ts
6966
+ var import_firestore4 = require("firebase/firestore");
6967
+
6968
+ // src/services/base.service.ts
6969
+ var import_storage = require("firebase/storage");
6970
+ var BaseService = class {
6971
+ constructor(db, auth, app, storage) {
6972
+ this.db = db;
6973
+ this.auth = auth;
6974
+ this.app = app;
6975
+ if (app) {
6976
+ this.storage = storage || (0, import_storage.getStorage)(app);
6977
+ }
6978
+ }
6979
+ /**
6980
+ * Generiše jedinstveni ID za dokumente
6981
+ * Format: xxxxxxxxxxxx-timestamp
6982
+ * Gde je x random karakter (broj ili slovo)
6983
+ */
6984
+ generateId() {
6985
+ const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
6986
+ const timestamp = Date.now().toString(36);
6987
+ const randomPart = Array.from(
6988
+ { length: 12 },
6989
+ () => chars.charAt(Math.floor(Math.random() * chars.length))
6990
+ ).join("");
6991
+ return `${randomPart}-${timestamp}`;
6991
6992
  }
6992
- /**
6993
- * Apply clinic working hours to available intervals
6994
- *
6995
- * @param intervals - Current available intervals
6996
- * @param workingHours - Clinic working hours
6997
- * @param timeframe - Overall timeframe being considered
6998
- * @param tz - IANA timezone of the clinic
6999
- * @returns Intervals filtered by clinic working hours
7000
- */
7001
- static applyClinicWorkingHours(intervals, workingHours, timeframe, tz) {
7002
- if (!intervals.length) return [];
7003
- console.log(
7004
- `Applying clinic working hours to ${intervals.length} intervals`
7005
- );
7006
- const workingIntervals = this.createWorkingHoursIntervals(
7007
- workingHours,
7008
- timeframe.start.toDate(),
7009
- timeframe.end.toDate(),
7010
- tz
7011
- );
7012
- return this.intersectIntervals(intervals, workingIntervals);
6993
+ };
6994
+
6995
+ // src/services/analytics/utils/cost-calculation.utils.ts
6996
+ function calculateAppointmentCost(appointment) {
6997
+ const metadata = appointment.metadata;
6998
+ const currency = appointment.currency || "CHF";
6999
+ if (metadata == null ? void 0 : metadata.finalbilling) {
7000
+ const finalbilling = metadata.finalbilling;
7001
+ return {
7002
+ cost: finalbilling.finalPrice,
7003
+ currency: finalbilling.currency || currency,
7004
+ source: "finalbilling",
7005
+ subtotal: finalbilling.subtotalAll,
7006
+ tax: finalbilling.taxPrice
7007
+ };
7013
7008
  }
7014
- /**
7015
- * Create time intervals for working hours across multiple days
7016
- *
7017
- * @param workingHours - Working hours definition
7018
- * @param startDate - Start date of the overall timeframe
7019
- * @param endDate - End date of the overall timeframe
7020
- * @param tz - IANA timezone of the clinic
7021
- * @returns Array of time intervals representing working hours
7022
- */
7023
- static createWorkingHoursIntervals(workingHours, startDate, endDate, tz) {
7024
- const workingIntervals = [];
7025
- let start = import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz });
7026
- const end = import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz });
7027
- while (start <= end) {
7028
- const dayOfWeek = start.weekday;
7029
- const dayName = [
7030
- "monday",
7031
- "tuesday",
7032
- "wednesday",
7033
- "thursday",
7034
- "friday",
7035
- "saturday",
7036
- "sunday"
7037
- ][dayOfWeek - 1];
7038
- if (dayName && workingHours[dayName]) {
7039
- const daySchedule = workingHours[dayName];
7040
- if (daySchedule) {
7041
- const [openHours, openMinutes] = daySchedule.open.split(":").map(Number);
7042
- const [closeHours, closeMinutes] = daySchedule.close.split(":").map(Number);
7043
- let workStart = start.set({
7044
- hour: openHours,
7045
- minute: openMinutes,
7046
- second: 0,
7047
- millisecond: 0
7048
- });
7049
- let workEnd = start.set({
7050
- hour: closeHours,
7051
- minute: closeMinutes,
7052
- second: 0,
7053
- millisecond: 0
7054
- });
7055
- if (workEnd.toMillis() > startDate.getTime() && workStart.toMillis() < endDate.getTime()) {
7056
- const intervalStart = workStart < import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz }) ? import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
7057
- const intervalEnd = workEnd > import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz }) ? import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
7058
- workingIntervals.push({
7059
- start: import_firestore2.Timestamp.fromMillis(intervalStart.toMillis()),
7060
- end: import_firestore2.Timestamp.fromMillis(intervalEnd.toMillis())
7061
- });
7062
- if (daySchedule.breaks && daySchedule.breaks.length > 0) {
7063
- for (const breakTime of daySchedule.breaks) {
7064
- const [breakStartHours, breakStartMinutes] = breakTime.start.split(":").map(Number);
7065
- const [breakEndHours, breakEndMinutes] = breakTime.end.split(":").map(Number);
7066
- const breakStart = start.set({
7067
- hour: breakStartHours,
7068
- minute: breakStartMinutes
7069
- });
7070
- const breakEnd = start.set({
7071
- hour: breakEndHours,
7072
- minute: breakEndMinutes
7073
- });
7074
- workingIntervals.splice(
7075
- -1,
7076
- 1,
7077
- ...this.subtractInterval(
7078
- workingIntervals[workingIntervals.length - 1],
7079
- {
7080
- start: import_firestore2.Timestamp.fromMillis(breakStart.toMillis()),
7081
- end: import_firestore2.Timestamp.fromMillis(breakEnd.toMillis())
7082
- }
7083
- )
7084
- );
7085
- }
7086
- }
7009
+ if (metadata == null ? void 0 : metadata.zonesData) {
7010
+ const zonesData = metadata.zonesData;
7011
+ let subtotal = 0;
7012
+ let foundCurrency = currency;
7013
+ Object.values(zonesData).forEach((items) => {
7014
+ items.forEach((item) => {
7015
+ if (item.type === "item" && item.subtotal) {
7016
+ subtotal += item.subtotal;
7017
+ if (item.currency && !foundCurrency) {
7018
+ foundCurrency = item.currency;
7087
7019
  }
7088
7020
  }
7089
- }
7090
- start = start.plus({ days: 1 });
7021
+ });
7022
+ });
7023
+ if (subtotal > 0) {
7024
+ return {
7025
+ cost: subtotal,
7026
+ // Note: This doesn't include tax, but zonesData might not have tax info
7027
+ currency: foundCurrency,
7028
+ source: "zonesData",
7029
+ subtotal
7030
+ };
7091
7031
  }
7092
- return workingIntervals;
7093
7032
  }
7094
- /**
7095
- * Subtract blocking events from available intervals
7096
- *
7097
- * @param intervals - Current available intervals
7098
- * @param events - Calendar events to subtract
7099
- * @returns Available intervals after removing blocking events
7100
- */
7101
- static subtractBlockingEvents(intervals, events) {
7102
- if (!intervals.length) return [];
7103
- console.log(`Subtracting ${events.length} blocking events`);
7104
- const blockingEvents = events.filter(
7105
- (event) => event.eventType === "blocking" /* BLOCKING */ || event.eventType === "break" /* BREAK */ || event.eventType === "free_day" /* FREE_DAY */
7106
- );
7107
- let result = [...intervals];
7108
- for (const event of blockingEvents) {
7109
- const { start, end } = event.eventTime;
7110
- const blockingInterval = { start, end };
7111
- const newResult = [];
7112
- for (const interval of result) {
7113
- const remainingIntervals = this.subtractInterval(
7114
- interval,
7115
- blockingInterval
7116
- );
7117
- newResult.push(...remainingIntervals);
7033
+ return {
7034
+ cost: appointment.cost || 0,
7035
+ currency,
7036
+ source: "baseCost"
7037
+ };
7038
+ }
7039
+ function calculateTotalRevenue(appointments) {
7040
+ if (appointments.length === 0) {
7041
+ return { totalRevenue: 0, currency: "CHF" };
7042
+ }
7043
+ let totalRevenue = 0;
7044
+ const currencies = /* @__PURE__ */ new Set();
7045
+ appointments.forEach((appointment) => {
7046
+ const costData = calculateAppointmentCost(appointment);
7047
+ totalRevenue += costData.cost;
7048
+ currencies.add(costData.currency);
7049
+ });
7050
+ const currency = currencies.size > 0 ? Array.from(currencies)[0] : "CHF";
7051
+ return { totalRevenue, currency };
7052
+ }
7053
+ function extractProductUsage(appointment) {
7054
+ const products = [];
7055
+ const metadata = appointment.metadata;
7056
+ if (!(metadata == null ? void 0 : metadata.zonesData)) {
7057
+ return products;
7058
+ }
7059
+ const zonesData = metadata.zonesData;
7060
+ const currency = appointment.currency || "CHF";
7061
+ Object.values(zonesData).forEach((items) => {
7062
+ items.forEach((item) => {
7063
+ if (item.type === "item" && item.productId) {
7064
+ const price = item.priceOverrideAmount || item.price || 0;
7065
+ const quantity = item.quantity || 1;
7066
+ const calculatedSubtotal = price * quantity;
7067
+ const storedSubtotal = item.subtotal || 0;
7068
+ const subtotal = Math.abs(storedSubtotal - calculatedSubtotal) < 0.01 ? storedSubtotal : calculatedSubtotal;
7069
+ products.push({
7070
+ productId: item.productId,
7071
+ productName: item.productName || "Unknown Product",
7072
+ brandId: item.productBrandId || "",
7073
+ brandName: item.productBrandName || "",
7074
+ quantity,
7075
+ price,
7076
+ subtotal,
7077
+ currency: item.currency || currency
7078
+ });
7118
7079
  }
7119
- result = newResult;
7080
+ });
7081
+ });
7082
+ return products;
7083
+ }
7084
+
7085
+ // src/services/analytics/utils/time-calculation.utils.ts
7086
+ function calculateTimeEfficiency(appointment) {
7087
+ const startTime = appointment.appointmentStartTime;
7088
+ const endTime = appointment.appointmentEndTime;
7089
+ if (!startTime || !endTime) {
7090
+ return null;
7091
+ }
7092
+ const bookedDurationMs = endTime.toMillis() - startTime.toMillis();
7093
+ const bookedDuration = Math.round(bookedDurationMs / (1e3 * 60));
7094
+ const actualDuration = appointment.actualDurationMinutes || bookedDuration;
7095
+ const efficiency = bookedDuration > 0 ? actualDuration / bookedDuration * 100 : 100;
7096
+ const overrun = actualDuration > bookedDuration ? actualDuration - bookedDuration : 0;
7097
+ const underutilization = bookedDuration > actualDuration ? bookedDuration - actualDuration : 0;
7098
+ return {
7099
+ bookedDuration,
7100
+ actualDuration,
7101
+ efficiency,
7102
+ overrun,
7103
+ underutilization
7104
+ };
7105
+ }
7106
+ function calculateAverageTimeMetrics(appointments) {
7107
+ if (appointments.length === 0) {
7108
+ return {
7109
+ averageBookedDuration: 0,
7110
+ averageActualDuration: 0,
7111
+ averageEfficiency: 0,
7112
+ totalOverrun: 0,
7113
+ totalUnderutilization: 0,
7114
+ averageOverrun: 0,
7115
+ averageUnderutilization: 0,
7116
+ appointmentsWithActualTime: 0
7117
+ };
7118
+ }
7119
+ let totalBookedDuration = 0;
7120
+ let totalActualDuration = 0;
7121
+ let totalOverrun = 0;
7122
+ let totalUnderutilization = 0;
7123
+ let appointmentsWithActualTime = 0;
7124
+ appointments.forEach((appointment) => {
7125
+ const timeData = calculateTimeEfficiency(appointment);
7126
+ if (timeData) {
7127
+ totalBookedDuration += timeData.bookedDuration;
7128
+ totalActualDuration += timeData.actualDuration;
7129
+ totalOverrun += timeData.overrun;
7130
+ totalUnderutilization += timeData.underutilization;
7131
+ if (appointment.actualDurationMinutes !== void 0) {
7132
+ appointmentsWithActualTime++;
7133
+ }
7134
+ }
7135
+ });
7136
+ const count = appointments.length;
7137
+ const averageBookedDuration = count > 0 ? totalBookedDuration / count : 0;
7138
+ const averageActualDuration = count > 0 ? totalActualDuration / count : 0;
7139
+ const averageEfficiency = averageBookedDuration > 0 ? averageActualDuration / averageBookedDuration * 100 : 0;
7140
+ const averageOverrun = count > 0 ? totalOverrun / count : 0;
7141
+ const averageUnderutilization = count > 0 ? totalUnderutilization / count : 0;
7142
+ return {
7143
+ averageBookedDuration: Math.round(averageBookedDuration),
7144
+ averageActualDuration: Math.round(averageActualDuration),
7145
+ averageEfficiency: Math.round(averageEfficiency * 100) / 100,
7146
+ totalOverrun,
7147
+ totalUnderutilization,
7148
+ averageOverrun: Math.round(averageOverrun),
7149
+ averageUnderutilization: Math.round(averageUnderutilization),
7150
+ appointmentsWithActualTime
7151
+ };
7152
+ }
7153
+ function calculateEfficiencyDistribution(appointments) {
7154
+ const ranges = [
7155
+ { label: "0-50%", min: 0, max: 50 },
7156
+ { label: "50-75%", min: 50, max: 75 },
7157
+ { label: "75-100%", min: 75, max: 100 },
7158
+ { label: "100%+", min: 100, max: Infinity }
7159
+ ];
7160
+ const distribution = ranges.map((range) => ({
7161
+ range: range.label,
7162
+ count: 0,
7163
+ percentage: 0
7164
+ }));
7165
+ let validCount = 0;
7166
+ appointments.forEach((appointment) => {
7167
+ const timeData = calculateTimeEfficiency(appointment);
7168
+ if (timeData) {
7169
+ validCount++;
7170
+ const efficiency = timeData.efficiency;
7171
+ for (let i = 0; i < ranges.length; i++) {
7172
+ if (efficiency >= ranges[i].min && efficiency < ranges[i].max) {
7173
+ distribution[i].count++;
7174
+ break;
7175
+ }
7176
+ }
7177
+ }
7178
+ });
7179
+ if (validCount > 0) {
7180
+ distribution.forEach((item) => {
7181
+ item.percentage = Math.round(item.count / validCount * 100 * 100) / 100;
7182
+ });
7183
+ }
7184
+ return distribution;
7185
+ }
7186
+ function calculateCancellationLeadTime(appointment) {
7187
+ if (!appointment.cancellationTime || !appointment.appointmentStartTime) {
7188
+ return null;
7189
+ }
7190
+ const cancellationTime = appointment.cancellationTime.toMillis();
7191
+ const appointmentTime = appointment.appointmentStartTime.toMillis();
7192
+ const diffMs = appointmentTime - cancellationTime;
7193
+ return Math.max(0, diffMs / (1e3 * 60 * 60));
7194
+ }
7195
+
7196
+ // src/services/analytics/utils/appointment-filtering.utils.ts
7197
+ function filterByDateRange(appointments, dateRange) {
7198
+ if (!dateRange) {
7199
+ return appointments;
7200
+ }
7201
+ const startTime = dateRange.start.getTime();
7202
+ const endTime = dateRange.end.getTime();
7203
+ return appointments.filter((appointment) => {
7204
+ const appointmentTime = appointment.appointmentStartTime.toMillis();
7205
+ return appointmentTime >= startTime && appointmentTime <= endTime;
7206
+ });
7207
+ }
7208
+ function filterAppointments(appointments, filters) {
7209
+ if (!filters) {
7210
+ return appointments;
7211
+ }
7212
+ return appointments.filter((appointment) => {
7213
+ if (filters.clinicBranchId && appointment.clinicBranchId !== filters.clinicBranchId) {
7214
+ return false;
7120
7215
  }
7121
- return result;
7216
+ if (filters.practitionerId && appointment.practitionerId !== filters.practitionerId) {
7217
+ return false;
7218
+ }
7219
+ if (filters.procedureId && appointment.procedureId !== filters.procedureId) {
7220
+ return false;
7221
+ }
7222
+ if (filters.patientId && appointment.patientId !== filters.patientId) {
7223
+ return false;
7224
+ }
7225
+ return true;
7226
+ });
7227
+ }
7228
+ function filterByStatus(appointments, statuses) {
7229
+ return appointments.filter((appointment) => statuses.includes(appointment.status));
7230
+ }
7231
+ function getCompletedAppointments(appointments) {
7232
+ return filterByStatus(appointments, ["completed" /* COMPLETED */]);
7233
+ }
7234
+ function getCanceledAppointments(appointments) {
7235
+ return filterByStatus(appointments, [
7236
+ "canceled_patient" /* CANCELED_PATIENT */,
7237
+ "canceled_clinic" /* CANCELED_CLINIC */,
7238
+ "canceled_patient_rescheduled" /* CANCELED_PATIENT_RESCHEDULED */
7239
+ ]);
7240
+ }
7241
+ function getNoShowAppointments(appointments) {
7242
+ return filterByStatus(appointments, ["no_show" /* NO_SHOW */]);
7243
+ }
7244
+ function calculatePercentage(part, total) {
7245
+ if (total === 0) {
7246
+ return 0;
7122
7247
  }
7123
- /**
7124
- * Apply practitioner's specific working hours for the given clinic
7125
- *
7126
- * @param intervals - Current available intervals
7127
- * @param practitioner - Practitioner object
7128
- * @param clinicId - ID of the clinic
7129
- * @param timeframe - Overall timeframe being considered
7130
- * @param tz - IANA timezone of the clinic
7131
- * @returns Intervals filtered by practitioner's working hours
7132
- */
7133
- static applyPractitionerWorkingHours(intervals, practitioner, clinicId, timeframe, tz) {
7134
- if (!intervals.length) return [];
7135
- console.log(`Applying practitioner working hours for clinic ${clinicId}`);
7136
- const clinicWorkingHours = practitioner.clinicWorkingHours.find(
7137
- (hours) => hours.clinicId === clinicId && hours.isActive
7248
+ return Math.round(part / total * 100 * 100) / 100;
7249
+ }
7250
+
7251
+ // src/services/analytics/utils/stored-analytics.utils.ts
7252
+ var import_firestore2 = require("firebase/firestore");
7253
+ function isAnalyticsDataFresh(computedAt, maxAgeHours) {
7254
+ const now = import_firestore2.Timestamp.now();
7255
+ const ageMs = now.toMillis() - computedAt.toMillis();
7256
+ const ageHours = ageMs / (1e3 * 60 * 60);
7257
+ return ageHours <= maxAgeHours;
7258
+ }
7259
+ async function readStoredAnalytics(db, clinicBranchId, subcollection, documentId, period) {
7260
+ try {
7261
+ const docRef = (0, import_firestore2.doc)(
7262
+ db,
7263
+ CLINICS_COLLECTION,
7264
+ clinicBranchId,
7265
+ ANALYTICS_COLLECTION,
7266
+ subcollection,
7267
+ period,
7268
+ documentId
7138
7269
  );
7139
- if (!clinicWorkingHours) {
7140
- console.log(
7141
- `No working hours found for practitioner at clinic ${clinicId}`
7142
- );
7143
- return [];
7270
+ const docSnap = await (0, import_firestore2.getDoc)(docRef);
7271
+ if (!docSnap.exists()) {
7272
+ return null;
7144
7273
  }
7145
- const workingIntervals = this.createPractitionerWorkingHoursIntervals(
7146
- clinicWorkingHours.workingHours,
7147
- timeframe.start.toDate(),
7148
- timeframe.end.toDate(),
7149
- tz
7150
- );
7274
+ return docSnap.data();
7275
+ } catch (error) {
7276
+ console.error(`[StoredAnalytics] Error reading ${subcollection}/${period}/${documentId}:`, error);
7277
+ return null;
7278
+ }
7279
+ }
7280
+ async function readStoredPractitionerAnalytics(db, clinicBranchId, practitionerId, options = {}) {
7281
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7282
+ if (!useCache) {
7283
+ return null;
7284
+ }
7285
+ const stored = await readStoredAnalytics(
7286
+ db,
7287
+ clinicBranchId,
7288
+ PRACTITIONER_ANALYTICS_SUBCOLLECTION,
7289
+ practitionerId,
7290
+ period
7291
+ );
7292
+ if (!stored) {
7293
+ return null;
7294
+ }
7295
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7296
+ return null;
7297
+ }
7298
+ return stored;
7299
+ }
7300
+ async function readStoredProcedureAnalytics(db, clinicBranchId, procedureId, options = {}) {
7301
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7302
+ if (!useCache) {
7303
+ return null;
7304
+ }
7305
+ const stored = await readStoredAnalytics(
7306
+ db,
7307
+ clinicBranchId,
7308
+ PROCEDURE_ANALYTICS_SUBCOLLECTION,
7309
+ procedureId,
7310
+ period
7311
+ );
7312
+ if (!stored) {
7313
+ return null;
7314
+ }
7315
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7316
+ return null;
7317
+ }
7318
+ return stored;
7319
+ }
7320
+ async function readStoredDashboardAnalytics(db, clinicBranchId, options = {}) {
7321
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7322
+ if (!useCache) {
7323
+ return null;
7324
+ }
7325
+ const stored = await readStoredAnalytics(
7326
+ db,
7327
+ clinicBranchId,
7328
+ DASHBOARD_ANALYTICS_SUBCOLLECTION,
7329
+ "current",
7330
+ period
7331
+ );
7332
+ if (!stored) {
7333
+ return null;
7334
+ }
7335
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7336
+ return null;
7337
+ }
7338
+ return stored;
7339
+ }
7340
+ async function readStoredTimeEfficiencyMetrics(db, clinicBranchId, options = {}) {
7341
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7342
+ if (!useCache) {
7343
+ return null;
7344
+ }
7345
+ const stored = await readStoredAnalytics(
7346
+ db,
7347
+ clinicBranchId,
7348
+ TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION,
7349
+ "current",
7350
+ period
7351
+ );
7352
+ if (!stored) {
7353
+ return null;
7354
+ }
7355
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7356
+ return null;
7357
+ }
7358
+ return stored;
7359
+ }
7360
+ async function readStoredRevenueMetrics(db, clinicBranchId, options = {}) {
7361
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7362
+ if (!useCache) {
7363
+ return null;
7364
+ }
7365
+ const stored = await readStoredAnalytics(
7366
+ db,
7367
+ clinicBranchId,
7368
+ REVENUE_ANALYTICS_SUBCOLLECTION,
7369
+ "current",
7370
+ period
7371
+ );
7372
+ if (!stored) {
7373
+ return null;
7374
+ }
7375
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7376
+ return null;
7377
+ }
7378
+ return stored;
7379
+ }
7380
+ async function readStoredCancellationMetrics(db, clinicBranchId, entityType, options = {}) {
7381
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7382
+ if (!useCache) {
7383
+ return null;
7384
+ }
7385
+ const stored = await readStoredAnalytics(
7386
+ db,
7387
+ clinicBranchId,
7388
+ CANCELLATION_ANALYTICS_SUBCOLLECTION,
7389
+ entityType,
7390
+ period
7391
+ );
7392
+ if (!stored) {
7393
+ return null;
7394
+ }
7395
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7396
+ return null;
7397
+ }
7398
+ return stored;
7399
+ }
7400
+ async function readStoredNoShowMetrics(db, clinicBranchId, entityType, options = {}) {
7401
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7402
+ if (!useCache) {
7403
+ return null;
7404
+ }
7405
+ const stored = await readStoredAnalytics(
7406
+ db,
7407
+ clinicBranchId,
7408
+ NO_SHOW_ANALYTICS_SUBCOLLECTION,
7409
+ entityType,
7410
+ period
7411
+ );
7412
+ if (!stored) {
7413
+ return null;
7414
+ }
7415
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7416
+ return null;
7417
+ }
7418
+ return stored;
7419
+ }
7420
+
7421
+ // src/services/analytics/utils/grouping.utils.ts
7422
+ function getTechnologyId(appointment) {
7423
+ var _a;
7424
+ return ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
7425
+ }
7426
+ function getTechnologyName(appointment) {
7427
+ var _a, _b;
7428
+ return ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyName) || ((_b = appointment.procedureInfo) == null ? void 0 : _b.technologyName) || "Unknown";
7429
+ }
7430
+ function getEntityName(appointment, entityType) {
7431
+ var _a, _b, _c, _d, _e, _f;
7432
+ switch (entityType) {
7433
+ case "clinic":
7434
+ return ((_a = appointment.clinicInfo) == null ? void 0 : _a.name) || "Unknown";
7435
+ case "practitioner":
7436
+ return ((_b = appointment.practitionerInfo) == null ? void 0 : _b.name) || "Unknown";
7437
+ case "patient":
7438
+ return ((_c = appointment.patientInfo) == null ? void 0 : _c.fullName) || "Unknown";
7439
+ case "procedure":
7440
+ return ((_d = appointment.procedureInfo) == null ? void 0 : _d.name) || "Unknown";
7441
+ case "technology":
7442
+ return ((_e = appointment.procedureExtendedInfo) == null ? void 0 : _e.procedureTechnologyName) || ((_f = appointment.procedureInfo) == null ? void 0 : _f.technologyName) || "Unknown";
7443
+ }
7444
+ }
7445
+ function getEntityId(appointment, entityType) {
7446
+ var _a;
7447
+ switch (entityType) {
7448
+ case "clinic":
7449
+ return appointment.clinicBranchId;
7450
+ case "practitioner":
7451
+ return appointment.practitionerId;
7452
+ case "patient":
7453
+ return appointment.patientId;
7454
+ case "procedure":
7455
+ return appointment.procedureId;
7456
+ case "technology":
7457
+ return ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
7458
+ }
7459
+ }
7460
+ function groupAppointmentsByEntity(appointments, entityType) {
7461
+ const entityMap = /* @__PURE__ */ new Map();
7462
+ appointments.forEach((appointment) => {
7463
+ let entityId;
7464
+ let entityName;
7465
+ if (entityType === "technology") {
7466
+ entityId = getTechnologyId(appointment);
7467
+ entityName = getTechnologyName(appointment);
7468
+ } else {
7469
+ entityId = getEntityId(appointment, entityType);
7470
+ entityName = getEntityName(appointment, entityType);
7471
+ }
7472
+ if (!entityMap.has(entityId)) {
7473
+ entityMap.set(entityId, { name: entityName, appointments: [] });
7474
+ }
7475
+ entityMap.get(entityId).appointments.push(appointment);
7476
+ });
7477
+ return entityMap;
7478
+ }
7479
+ function calculateGroupedRevenueMetrics(appointments, entityType) {
7480
+ const entityMap = groupAppointmentsByEntity(appointments, entityType);
7481
+ const completed = getCompletedAppointments(appointments);
7482
+ return Array.from(entityMap.entries()).map(([entityId, data]) => {
7483
+ var _a;
7484
+ const entityAppointments = data.appointments;
7485
+ const entityCompleted = entityAppointments.filter(
7486
+ (a) => completed.some((c) => c.id === a.id)
7487
+ );
7488
+ const { totalRevenue, currency } = calculateTotalRevenue(entityCompleted);
7489
+ let totalTax = 0;
7490
+ let totalSubtotal = 0;
7491
+ let unpaidRevenue = 0;
7492
+ let refundedRevenue = 0;
7493
+ entityCompleted.forEach((appointment) => {
7494
+ const costData = calculateAppointmentCost(appointment);
7495
+ if (costData.source === "finalbilling") {
7496
+ totalTax += costData.tax || 0;
7497
+ totalSubtotal += costData.subtotal || 0;
7498
+ } else {
7499
+ totalSubtotal += costData.cost;
7500
+ }
7501
+ if (appointment.paymentStatus === "unpaid") {
7502
+ unpaidRevenue += costData.cost;
7503
+ } else if (appointment.paymentStatus === "refunded") {
7504
+ refundedRevenue += costData.cost;
7505
+ }
7506
+ });
7507
+ let practitionerId;
7508
+ let practitionerName;
7509
+ if (entityType === "procedure" && entityAppointments.length > 0) {
7510
+ const firstAppointment = entityAppointments[0];
7511
+ practitionerId = firstAppointment.practitionerId;
7512
+ practitionerName = (_a = firstAppointment.practitionerInfo) == null ? void 0 : _a.name;
7513
+ }
7514
+ return {
7515
+ entityId,
7516
+ entityName: data.name,
7517
+ entityType,
7518
+ totalRevenue,
7519
+ averageRevenuePerAppointment: entityCompleted.length > 0 ? totalRevenue / entityCompleted.length : 0,
7520
+ totalAppointments: entityAppointments.length,
7521
+ completedAppointments: entityCompleted.length,
7522
+ currency,
7523
+ unpaidRevenue,
7524
+ refundedRevenue,
7525
+ totalTax,
7526
+ totalSubtotal,
7527
+ ...practitionerId && { practitionerId },
7528
+ ...practitionerName && { practitionerName }
7529
+ };
7530
+ });
7531
+ }
7532
+ function calculateGroupedProductUsageMetrics(appointments, entityType) {
7533
+ const entityMap = groupAppointmentsByEntity(appointments, entityType);
7534
+ const completed = getCompletedAppointments(appointments);
7535
+ return Array.from(entityMap.entries()).map(([entityId, data]) => {
7536
+ var _a;
7537
+ const entityAppointments = data.appointments;
7538
+ const entityCompleted = entityAppointments.filter(
7539
+ (a) => completed.some((c) => c.id === a.id)
7540
+ );
7541
+ const productMap = /* @__PURE__ */ new Map();
7542
+ entityCompleted.forEach((appointment) => {
7543
+ const products = extractProductUsage(appointment);
7544
+ products.forEach((product) => {
7545
+ if (productMap.has(product.productId)) {
7546
+ const existing = productMap.get(product.productId);
7547
+ existing.quantity += product.quantity;
7548
+ existing.revenue += product.subtotal;
7549
+ existing.usageCount++;
7550
+ } else {
7551
+ productMap.set(product.productId, {
7552
+ name: product.productName,
7553
+ brandName: product.brandName,
7554
+ quantity: product.quantity,
7555
+ revenue: product.subtotal,
7556
+ usageCount: 1
7557
+ });
7558
+ }
7559
+ });
7560
+ });
7561
+ const topProducts = Array.from(productMap.entries()).map(([productId, productData]) => ({
7562
+ productId,
7563
+ productName: productData.name,
7564
+ brandName: productData.brandName,
7565
+ totalQuantity: productData.quantity,
7566
+ totalRevenue: productData.revenue,
7567
+ usageCount: productData.usageCount
7568
+ })).sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, 10);
7569
+ const totalProductRevenue = topProducts.reduce((sum, p) => sum + p.totalRevenue, 0);
7570
+ const totalProductQuantity = topProducts.reduce((sum, p) => sum + p.totalQuantity, 0);
7571
+ let practitionerId;
7572
+ let practitionerName;
7573
+ if (entityType === "procedure" && entityAppointments.length > 0) {
7574
+ const firstAppointment = entityAppointments[0];
7575
+ practitionerId = firstAppointment.practitionerId;
7576
+ practitionerName = (_a = firstAppointment.practitionerInfo) == null ? void 0 : _a.name;
7577
+ }
7578
+ return {
7579
+ entityId,
7580
+ entityName: data.name,
7581
+ entityType,
7582
+ totalProductsUsed: productMap.size,
7583
+ uniqueProducts: productMap.size,
7584
+ totalProductRevenue,
7585
+ totalProductQuantity,
7586
+ averageProductsPerAppointment: entityCompleted.length > 0 ? productMap.size / entityCompleted.length : 0,
7587
+ topProducts,
7588
+ ...practitionerId && { practitionerId },
7589
+ ...practitionerName && { practitionerName }
7590
+ };
7591
+ });
7592
+ }
7593
+ function calculateGroupedTimeEfficiencyMetrics(appointments, entityType) {
7594
+ const entityMap = groupAppointmentsByEntity(appointments, entityType);
7595
+ const completed = getCompletedAppointments(appointments);
7596
+ return Array.from(entityMap.entries()).map(([entityId, data]) => {
7597
+ var _a;
7598
+ const entityAppointments = data.appointments;
7599
+ const entityCompleted = entityAppointments.filter(
7600
+ (a) => completed.some((c) => c.id === a.id)
7601
+ );
7602
+ const timeMetrics = calculateAverageTimeMetrics(entityCompleted);
7603
+ let practitionerId;
7604
+ let practitionerName;
7605
+ if (entityType === "procedure" && entityAppointments.length > 0) {
7606
+ const firstAppointment = entityAppointments[0];
7607
+ practitionerId = firstAppointment.practitionerId;
7608
+ practitionerName = (_a = firstAppointment.practitionerInfo) == null ? void 0 : _a.name;
7609
+ }
7610
+ return {
7611
+ entityId,
7612
+ entityName: data.name,
7613
+ entityType,
7614
+ totalAppointments: entityCompleted.length,
7615
+ appointmentsWithActualTime: timeMetrics.appointmentsWithActualTime,
7616
+ averageBookedDuration: timeMetrics.averageBookedDuration,
7617
+ averageActualDuration: timeMetrics.averageActualDuration,
7618
+ averageEfficiency: timeMetrics.averageEfficiency,
7619
+ totalOverrun: timeMetrics.totalOverrun,
7620
+ totalUnderutilization: timeMetrics.totalUnderutilization,
7621
+ averageOverrun: timeMetrics.averageOverrun,
7622
+ averageUnderutilization: timeMetrics.averageUnderutilization,
7623
+ ...practitionerId && { practitionerId },
7624
+ ...practitionerName && { practitionerName }
7625
+ };
7626
+ });
7627
+ }
7628
+ function calculateGroupedPatientBehaviorMetrics(appointments, entityType) {
7629
+ const entityMap = groupAppointmentsByEntity(appointments, entityType);
7630
+ const canceled = getCanceledAppointments(appointments);
7631
+ const noShow = getNoShowAppointments(appointments);
7632
+ return Array.from(entityMap.entries()).map(([entityId, data]) => {
7633
+ const entityAppointments = data.appointments;
7634
+ const patientMap = /* @__PURE__ */ new Map();
7635
+ entityAppointments.forEach((appointment) => {
7636
+ var _a;
7637
+ const patientId = appointment.patientId;
7638
+ const patientName = ((_a = appointment.patientInfo) == null ? void 0 : _a.fullName) || "Unknown";
7639
+ if (!patientMap.has(patientId)) {
7640
+ patientMap.set(patientId, {
7641
+ name: patientName,
7642
+ appointments: [],
7643
+ noShows: [],
7644
+ cancellations: []
7645
+ });
7646
+ }
7647
+ const patientData = patientMap.get(patientId);
7648
+ patientData.appointments.push(appointment);
7649
+ if (noShow.some((ns) => ns.id === appointment.id)) {
7650
+ patientData.noShows.push(appointment);
7651
+ }
7652
+ if (canceled.some((c) => c.id === appointment.id)) {
7653
+ patientData.cancellations.push(appointment);
7654
+ }
7655
+ });
7656
+ const patientMetrics = Array.from(patientMap.entries()).map(([patientId, patientData]) => ({
7657
+ patientId,
7658
+ patientName: patientData.name,
7659
+ noShowCount: patientData.noShows.length,
7660
+ cancellationCount: patientData.cancellations.length,
7661
+ totalAppointments: patientData.appointments.length,
7662
+ noShowRate: calculatePercentage(
7663
+ patientData.noShows.length,
7664
+ patientData.appointments.length
7665
+ ),
7666
+ cancellationRate: calculatePercentage(
7667
+ patientData.cancellations.length,
7668
+ patientData.appointments.length
7669
+ )
7670
+ }));
7671
+ const patientsWithNoShows = patientMetrics.filter((p) => p.noShowCount > 0).length;
7672
+ const patientsWithCancellations = patientMetrics.filter((p) => p.cancellationCount > 0).length;
7673
+ const averageNoShowRate = patientMetrics.length > 0 ? patientMetrics.reduce((sum, p) => sum + p.noShowRate, 0) / patientMetrics.length : 0;
7674
+ const averageCancellationRate = patientMetrics.length > 0 ? patientMetrics.reduce((sum, p) => sum + p.cancellationRate, 0) / patientMetrics.length : 0;
7675
+ const topNoShowPatients = patientMetrics.filter((p) => p.noShowCount > 0).sort((a, b) => b.noShowRate - a.noShowRate).slice(0, 10).map((p) => ({
7676
+ patientId: p.patientId,
7677
+ patientName: p.patientName,
7678
+ noShowCount: p.noShowCount,
7679
+ totalAppointments: p.totalAppointments,
7680
+ noShowRate: p.noShowRate
7681
+ }));
7682
+ const topCancellationPatients = patientMetrics.filter((p) => p.cancellationCount > 0).sort((a, b) => b.cancellationRate - a.cancellationRate).slice(0, 10).map((p) => ({
7683
+ patientId: p.patientId,
7684
+ patientName: p.patientName,
7685
+ cancellationCount: p.cancellationCount,
7686
+ totalAppointments: p.totalAppointments,
7687
+ cancellationRate: p.cancellationRate
7688
+ }));
7689
+ const newPatients = patientMetrics.filter((p) => p.totalAppointments === 1).length;
7690
+ const returningPatients = patientMetrics.filter((p) => p.totalAppointments > 1).length;
7691
+ return {
7692
+ entityId,
7693
+ entityName: data.name,
7694
+ entityType,
7695
+ totalPatients: patientMap.size,
7696
+ patientsWithNoShows,
7697
+ patientsWithCancellations,
7698
+ averageNoShowRate: Math.round(averageNoShowRate * 100) / 100,
7699
+ averageCancellationRate: Math.round(averageCancellationRate * 100) / 100,
7700
+ topNoShowPatients,
7701
+ topCancellationPatients
7702
+ };
7703
+ });
7704
+ }
7705
+
7706
+ // src/services/analytics/utils/trend-calculation.utils.ts
7707
+ function getPeriodDates(date, period) {
7708
+ const year = date.getFullYear();
7709
+ const month = date.getMonth();
7710
+ const day = date.getDate();
7711
+ let startDate;
7712
+ let endDate;
7713
+ let periodString;
7714
+ switch (period) {
7715
+ case "week": {
7716
+ const dayOfWeek = date.getDay();
7717
+ const diff = date.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1);
7718
+ startDate = new Date(year, month, diff);
7719
+ startDate.setHours(0, 0, 0, 0);
7720
+ endDate = new Date(startDate);
7721
+ endDate.setDate(endDate.getDate() + 6);
7722
+ endDate.setHours(23, 59, 59, 999);
7723
+ const weekNumber = getWeekNumber(date);
7724
+ periodString = `${year}-W${weekNumber.toString().padStart(2, "0")}`;
7725
+ break;
7726
+ }
7727
+ case "month": {
7728
+ startDate = new Date(year, month, 1);
7729
+ startDate.setHours(0, 0, 0, 0);
7730
+ endDate = new Date(year, month + 1, 0);
7731
+ endDate.setHours(23, 59, 59, 999);
7732
+ periodString = `${year}-${(month + 1).toString().padStart(2, "0")}`;
7733
+ break;
7734
+ }
7735
+ case "quarter": {
7736
+ const quarter = Math.floor(month / 3);
7737
+ const quarterStartMonth = quarter * 3;
7738
+ startDate = new Date(year, quarterStartMonth, 1);
7739
+ startDate.setHours(0, 0, 0, 0);
7740
+ endDate = new Date(year, quarterStartMonth + 3, 0);
7741
+ endDate.setHours(23, 59, 59, 999);
7742
+ periodString = `${year}-Q${quarter + 1}`;
7743
+ break;
7744
+ }
7745
+ case "year": {
7746
+ startDate = new Date(year, 0, 1);
7747
+ startDate.setHours(0, 0, 0, 0);
7748
+ endDate = new Date(year, 11, 31);
7749
+ endDate.setHours(23, 59, 59, 999);
7750
+ periodString = `${year}`;
7751
+ break;
7752
+ }
7753
+ }
7754
+ return {
7755
+ period: periodString,
7756
+ startDate,
7757
+ endDate
7758
+ };
7759
+ }
7760
+ function getWeekNumber(date) {
7761
+ const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
7762
+ const dayNum = d.getUTCDay() || 7;
7763
+ d.setUTCDate(d.getUTCDate() + 4 - dayNum);
7764
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
7765
+ return Math.ceil(((d.getTime() - yearStart.getTime()) / 864e5 + 1) / 7);
7766
+ }
7767
+ function groupAppointmentsByPeriod(appointments, period) {
7768
+ const periodMap = /* @__PURE__ */ new Map();
7769
+ appointments.forEach((appointment) => {
7770
+ const appointmentDate = appointment.appointmentStartTime.toDate();
7771
+ const periodInfo = getPeriodDates(appointmentDate, period);
7772
+ const periodKey = periodInfo.period;
7773
+ if (!periodMap.has(periodKey)) {
7774
+ periodMap.set(periodKey, []);
7775
+ }
7776
+ periodMap.get(periodKey).push(appointment);
7777
+ });
7778
+ return periodMap;
7779
+ }
7780
+ function generatePeriods(startDate, endDate, period) {
7781
+ const periods = [];
7782
+ const current = new Date(startDate);
7783
+ while (current <= endDate) {
7784
+ const periodInfo = getPeriodDates(current, period);
7785
+ if (periodInfo.endDate >= startDate && periodInfo.startDate <= endDate) {
7786
+ periods.push(periodInfo);
7787
+ }
7788
+ switch (period) {
7789
+ case "week":
7790
+ current.setDate(current.getDate() + 7);
7791
+ break;
7792
+ case "month":
7793
+ current.setMonth(current.getMonth() + 1);
7794
+ break;
7795
+ case "quarter":
7796
+ current.setMonth(current.getMonth() + 3);
7797
+ break;
7798
+ case "year":
7799
+ current.setFullYear(current.getFullYear() + 1);
7800
+ break;
7801
+ }
7802
+ }
7803
+ return periods.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
7804
+ }
7805
+ function calculatePercentageChange(current, previous) {
7806
+ if (previous === 0) {
7807
+ return current > 0 ? 100 : 0;
7808
+ }
7809
+ return (current - previous) / previous * 100;
7810
+ }
7811
+ function getTrendChange(current, previous) {
7812
+ const percentageChange = calculatePercentageChange(current, previous);
7813
+ const direction = percentageChange > 0.01 ? "up" : percentageChange < -0.01 ? "down" : "stable";
7814
+ return {
7815
+ value: current,
7816
+ previousValue: previous,
7817
+ percentageChange: Math.abs(percentageChange),
7818
+ direction
7819
+ };
7820
+ }
7821
+
7822
+ // src/services/analytics/review-analytics.service.ts
7823
+ var import_firestore3 = require("firebase/firestore");
7824
+ var ReviewAnalyticsService = class extends BaseService {
7825
+ constructor(db, auth, app, appointmentService) {
7826
+ super(db, auth, app);
7827
+ this.appointmentService = appointmentService;
7828
+ }
7829
+ /**
7830
+ * Fetches reviews filtered by date range and optional filters
7831
+ * Properly filters by clinic branch by checking appointment's clinicId
7832
+ */
7833
+ async fetchReviews(dateRange, filters) {
7834
+ let q = (0, import_firestore3.query)((0, import_firestore3.collection)(this.db, REVIEWS_COLLECTION));
7835
+ if (dateRange) {
7836
+ const startTimestamp = import_firestore3.Timestamp.fromDate(dateRange.start);
7837
+ const endTimestamp = import_firestore3.Timestamp.fromDate(dateRange.end);
7838
+ q = (0, import_firestore3.query)(q, (0, import_firestore3.where)("createdAt", ">=", startTimestamp), (0, import_firestore3.where)("createdAt", "<=", endTimestamp));
7839
+ }
7840
+ const snapshot = await (0, import_firestore3.getDocs)(q);
7841
+ const reviews = snapshot.docs.map((doc3) => {
7842
+ var _a, _b;
7843
+ const data = doc3.data();
7844
+ return {
7845
+ ...data,
7846
+ id: doc3.id,
7847
+ createdAt: ((_a = data.createdAt) == null ? void 0 : _a.toDate) ? data.createdAt.toDate() : new Date(data.createdAt),
7848
+ updatedAt: ((_b = data.updatedAt) == null ? void 0 : _b.toDate) ? data.updatedAt.toDate() : new Date(data.updatedAt)
7849
+ };
7850
+ });
7851
+ console.log(`[ReviewAnalytics] Fetched ${reviews.length} reviews in date range`);
7852
+ if ((filters == null ? void 0 : filters.clinicBranchId) && reviews.length > 0) {
7853
+ const appointmentIds = [...new Set(reviews.map((r) => r.appointmentId))];
7854
+ console.log(`[ReviewAnalytics] Filtering by clinic ${filters.clinicBranchId}, checking ${appointmentIds.length} appointments`);
7855
+ const validAppointmentIds = /* @__PURE__ */ new Set();
7856
+ for (let i = 0; i < appointmentIds.length; i += 10) {
7857
+ const batch = appointmentIds.slice(i, i + 10);
7858
+ const appointmentsQuery = (0, import_firestore3.query)(
7859
+ (0, import_firestore3.collection)(this.db, APPOINTMENTS_COLLECTION),
7860
+ (0, import_firestore3.where)("id", "in", batch)
7861
+ );
7862
+ const appointmentSnapshot = await (0, import_firestore3.getDocs)(appointmentsQuery);
7863
+ appointmentSnapshot.docs.forEach((doc3) => {
7864
+ const appointment = doc3.data();
7865
+ if (appointment.clinicBranchId === filters.clinicBranchId) {
7866
+ validAppointmentIds.add(doc3.id);
7867
+ }
7868
+ });
7869
+ }
7870
+ const filteredReviews = reviews.filter((review) => validAppointmentIds.has(review.appointmentId));
7871
+ console.log(`[ReviewAnalytics] After clinic filter: ${filteredReviews.length} reviews (from ${validAppointmentIds.size} valid appointments)`);
7872
+ return filteredReviews;
7873
+ }
7874
+ return reviews;
7875
+ }
7876
+ /**
7877
+ * Gets review metrics for a specific entity
7878
+ */
7879
+ async getReviewMetricsByEntity(entityType, entityId, dateRange, filters) {
7880
+ const reviews = await this.fetchReviews(dateRange, filters);
7881
+ let relevantReviews = [];
7882
+ if (entityType === "practitioner") {
7883
+ relevantReviews = reviews.filter((r) => {
7884
+ var _a;
7885
+ return ((_a = r.practitionerReview) == null ? void 0 : _a.practitionerId) === entityId;
7886
+ });
7887
+ } else if (entityType === "procedure") {
7888
+ relevantReviews = reviews.filter((r) => {
7889
+ var _a;
7890
+ return ((_a = r.procedureReview) == null ? void 0 : _a.procedureId) === entityId;
7891
+ });
7892
+ } else if (entityType === "category" || entityType === "subcategory") {
7893
+ relevantReviews = reviews;
7894
+ }
7895
+ if (relevantReviews.length === 0) {
7896
+ return null;
7897
+ }
7898
+ return this.calculateReviewMetrics(relevantReviews, entityType, entityId);
7899
+ }
7900
+ /**
7901
+ * Gets review metrics for multiple entities (grouped)
7902
+ */
7903
+ async getReviewMetricsByEntities(entityType, dateRange, filters) {
7904
+ const reviews = await this.fetchReviews(dateRange, filters);
7905
+ const entityMap = /* @__PURE__ */ new Map();
7906
+ let practitionerNameMap = null;
7907
+ let procedureNameMap = null;
7908
+ let procedureToTechnologyMap = null;
7909
+ if (entityType === "practitioner" || entityType === "procedure" || entityType === "technology") {
7910
+ if (!this.appointmentService) {
7911
+ console.warn(`[ReviewAnalytics] AppointmentService not available for ${entityType} name resolution`);
7912
+ return [];
7913
+ }
7914
+ console.log(`[ReviewAnalytics] Grouping by ${entityType}, fetching appointments for name resolution...`);
7915
+ const searchParams = {
7916
+ ...filters
7917
+ };
7918
+ if (dateRange) {
7919
+ searchParams.startDate = dateRange.start;
7920
+ searchParams.endDate = dateRange.end;
7921
+ }
7922
+ const appointmentsResult = await this.appointmentService.searchAppointments(searchParams);
7923
+ const appointments = appointmentsResult.appointments || [];
7924
+ console.log(`[ReviewAnalytics] Found ${appointments.length} appointments for name resolution`);
7925
+ practitionerNameMap = /* @__PURE__ */ new Map();
7926
+ procedureNameMap = /* @__PURE__ */ new Map();
7927
+ procedureToTechnologyMap = /* @__PURE__ */ new Map();
7928
+ appointments.forEach((appointment) => {
7929
+ var _a, _b, _c, _d, _e, _f;
7930
+ if (appointment.practitionerId && ((_a = appointment.practitionerInfo) == null ? void 0 : _a.name)) {
7931
+ practitionerNameMap.set(appointment.practitionerId, appointment.practitionerInfo.name);
7932
+ }
7933
+ if (appointment.procedureId) {
7934
+ if ((_b = appointment.procedureInfo) == null ? void 0 : _b.name) {
7935
+ procedureNameMap.set(appointment.procedureId, appointment.procedureInfo.name);
7936
+ }
7937
+ const mainTechnologyId = ((_c = appointment.procedureExtendedInfo) == null ? void 0 : _c.procedureTechnologyId) || "unknown-technology";
7938
+ const mainTechnologyName = ((_d = appointment.procedureExtendedInfo) == null ? void 0 : _d.procedureTechnologyName) || ((_e = appointment.procedureInfo) == null ? void 0 : _e.name) || "Unknown Technology";
7939
+ procedureToTechnologyMap.set(appointment.procedureId, {
7940
+ id: mainTechnologyId,
7941
+ name: mainTechnologyName
7942
+ });
7943
+ }
7944
+ if ((_f = appointment.metadata) == null ? void 0 : _f.extendedProcedures) {
7945
+ appointment.metadata.extendedProcedures.forEach((extendedProc) => {
7946
+ if (extendedProc.procedureId) {
7947
+ if (extendedProc.procedureName) {
7948
+ procedureNameMap.set(extendedProc.procedureId, extendedProc.procedureName);
7949
+ }
7950
+ const extTechnologyId = extendedProc.procedureTechnologyId || "unknown-technology";
7951
+ const extTechnologyName = extendedProc.procedureTechnologyName || "Unknown Technology";
7952
+ procedureToTechnologyMap.set(extendedProc.procedureId, {
7953
+ id: extTechnologyId,
7954
+ name: extTechnologyName
7955
+ });
7956
+ }
7957
+ });
7958
+ }
7959
+ });
7960
+ console.log(`[ReviewAnalytics] Built name maps: ${practitionerNameMap.size} practitioners, ${procedureNameMap.size} procedures, ${procedureToTechnologyMap.size} technologies`);
7961
+ }
7962
+ if (entityType === "technology" && procedureToTechnologyMap) {
7963
+ let processedReviewCount = 0;
7964
+ reviews.forEach((review) => {
7965
+ var _a;
7966
+ if ((_a = review.procedureReview) == null ? void 0 : _a.procedureId) {
7967
+ const techInfo = procedureToTechnologyMap.get(review.procedureReview.procedureId);
7968
+ if (techInfo) {
7969
+ if (!entityMap.has(techInfo.id)) {
7970
+ entityMap.set(techInfo.id, { reviews: [], name: techInfo.name });
7971
+ }
7972
+ entityMap.get(techInfo.id).reviews.push(review);
7973
+ processedReviewCount++;
7974
+ }
7975
+ }
7976
+ if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
7977
+ review.extendedProcedureReviews.forEach((extendedReview) => {
7978
+ if (extendedReview.procedureId) {
7979
+ const techInfo = procedureToTechnologyMap.get(extendedReview.procedureId);
7980
+ if (techInfo) {
7981
+ if (!entityMap.has(techInfo.id)) {
7982
+ entityMap.set(techInfo.id, { reviews: [], name: techInfo.name });
7983
+ }
7984
+ const reviewWithExtendedOnly = {
7985
+ ...review,
7986
+ procedureReview: extendedReview,
7987
+ extendedProcedureReviews: void 0
7988
+ };
7989
+ entityMap.get(techInfo.id).reviews.push(reviewWithExtendedOnly);
7990
+ processedReviewCount++;
7991
+ }
7992
+ }
7993
+ });
7994
+ }
7995
+ });
7996
+ console.log(`[ReviewAnalytics] Processed ${processedReviewCount} procedure reviews into ${entityMap.size} technology groups`);
7997
+ entityMap.forEach((data, techId) => {
7998
+ console.log(`[ReviewAnalytics] - ${data.name} (${techId}): ${data.reviews.length} reviews`);
7999
+ });
8000
+ } else if (entityType === "procedure" && procedureNameMap) {
8001
+ let processedReviewCount = 0;
8002
+ reviews.forEach((review) => {
8003
+ if (review.procedureReview) {
8004
+ const procedureId = review.procedureReview.procedureId;
8005
+ const procedureName = procedureId && procedureNameMap.get(procedureId) || review.procedureReview.procedureName || "Unknown Procedure";
8006
+ if (procedureId) {
8007
+ if (!entityMap.has(procedureId)) {
8008
+ entityMap.set(procedureId, { reviews: [], name: procedureName });
8009
+ }
8010
+ entityMap.get(procedureId).reviews.push(review);
8011
+ processedReviewCount++;
8012
+ }
8013
+ }
8014
+ if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
8015
+ review.extendedProcedureReviews.forEach((extendedReview) => {
8016
+ const procedureId = extendedReview.procedureId;
8017
+ const procedureName = procedureId && procedureNameMap.get(procedureId) || extendedReview.procedureName || "Unknown Procedure";
8018
+ if (procedureId) {
8019
+ if (!entityMap.has(procedureId)) {
8020
+ entityMap.set(procedureId, { reviews: [], name: procedureName });
8021
+ }
8022
+ const reviewWithExtendedOnly = {
8023
+ ...review,
8024
+ procedureReview: extendedReview,
8025
+ extendedProcedureReviews: void 0
8026
+ };
8027
+ entityMap.get(procedureId).reviews.push(reviewWithExtendedOnly);
8028
+ processedReviewCount++;
8029
+ }
8030
+ });
8031
+ }
8032
+ });
8033
+ console.log(`[ReviewAnalytics] Processed ${processedReviewCount} procedure reviews into ${entityMap.size} procedure groups`);
8034
+ entityMap.forEach((data, procId) => {
8035
+ console.log(`[ReviewAnalytics] - ${data.name} (${procId}): ${data.reviews.length} reviews`);
8036
+ });
8037
+ } else if (entityType === "practitioner" && practitionerNameMap) {
8038
+ reviews.forEach((review) => {
8039
+ if (review.practitionerReview) {
8040
+ const practitionerId = review.practitionerReview.practitionerId;
8041
+ const practitionerName = practitionerId && practitionerNameMap.get(practitionerId) || review.practitionerReview.practitionerName || "Unknown Practitioner";
8042
+ if (practitionerId) {
8043
+ if (!entityMap.has(practitionerId)) {
8044
+ entityMap.set(practitionerId, { reviews: [], name: practitionerName });
8045
+ }
8046
+ entityMap.get(practitionerId).reviews.push(review);
8047
+ }
8048
+ }
8049
+ });
8050
+ console.log(`[ReviewAnalytics] Processed ${reviews.length} reviews into ${entityMap.size} practitioner groups`);
8051
+ entityMap.forEach((data, practId) => {
8052
+ console.log(`[ReviewAnalytics] - ${data.name} (${practId}): ${data.reviews.length} reviews`);
8053
+ });
8054
+ } else {
8055
+ reviews.forEach((review) => {
8056
+ let entityId;
8057
+ let entityName;
8058
+ if (entityId) {
8059
+ if (!entityMap.has(entityId)) {
8060
+ entityMap.set(entityId, { reviews: [], name: entityName || entityId });
8061
+ }
8062
+ entityMap.get(entityId).reviews.push(review);
8063
+ }
8064
+ });
8065
+ }
8066
+ const metrics = [];
8067
+ for (const [entityId, data] of entityMap.entries()) {
8068
+ const metric = this.calculateReviewMetrics(data.reviews, entityType, entityId);
8069
+ if (metric) {
8070
+ metric.entityName = data.name;
8071
+ metrics.push(metric);
8072
+ }
8073
+ }
8074
+ return metrics;
8075
+ }
8076
+ /**
8077
+ * Calculates review metrics from a list of reviews
8078
+ */
8079
+ calculateReviewMetrics(reviews, entityType, entityId) {
8080
+ if (reviews.length === 0) {
8081
+ return null;
8082
+ }
8083
+ let totalRating = 0;
8084
+ let recommendationCount = 0;
8085
+ let practitionerMetrics;
8086
+ let procedureMetrics;
8087
+ let entityName = entityId;
8088
+ if (entityType === "practitioner") {
8089
+ const practitionerReviews = reviews.filter((r) => r.practitionerReview).map((r) => r.practitionerReview);
8090
+ if (practitionerReviews.length === 0) {
8091
+ return null;
8092
+ }
8093
+ entityName = practitionerReviews[0].practitionerName || entityId;
8094
+ totalRating = practitionerReviews.reduce((sum, r) => sum + r.overallRating, 0);
8095
+ recommendationCount = practitionerReviews.filter((r) => r.wouldRecommend).length;
8096
+ practitionerMetrics = {
8097
+ averageKnowledgeAndExpertise: this.calculateAverage(practitionerReviews.map((r) => r.knowledgeAndExpertise)),
8098
+ averageCommunicationSkills: this.calculateAverage(practitionerReviews.map((r) => r.communicationSkills)),
8099
+ averageBedSideManner: this.calculateAverage(practitionerReviews.map((r) => r.bedSideManner)),
8100
+ averageThoroughness: this.calculateAverage(practitionerReviews.map((r) => r.thoroughness)),
8101
+ averageTrustworthiness: this.calculateAverage(practitionerReviews.map((r) => r.trustworthiness))
8102
+ };
8103
+ } else if (entityType === "procedure" || entityType === "technology") {
8104
+ const procedureReviews = reviews.filter((r) => r.procedureReview).map((r) => r.procedureReview);
8105
+ if (procedureReviews.length === 0) {
8106
+ return null;
8107
+ }
8108
+ if (entityType === "procedure") {
8109
+ entityName = procedureReviews[0].procedureName || entityId;
8110
+ }
8111
+ totalRating = procedureReviews.reduce((sum, r) => sum + r.overallRating, 0);
8112
+ recommendationCount = procedureReviews.filter((r) => r.wouldRecommend).length;
8113
+ procedureMetrics = {
8114
+ averageEffectiveness: this.calculateAverage(procedureReviews.map((r) => r.effectivenessOfTreatment)),
8115
+ averageOutcomeExplanation: this.calculateAverage(procedureReviews.map((r) => r.outcomeExplanation)),
8116
+ averagePainManagement: this.calculateAverage(procedureReviews.map((r) => r.painManagement)),
8117
+ averageFollowUpCare: this.calculateAverage(procedureReviews.map((r) => r.followUpCare)),
8118
+ averageValueForMoney: this.calculateAverage(procedureReviews.map((r) => r.valueForMoney))
8119
+ };
8120
+ }
8121
+ const averageRating = totalRating / reviews.length;
8122
+ const recommendationRate = recommendationCount / reviews.length * 100;
8123
+ const result = {
8124
+ entityId,
8125
+ entityName,
8126
+ entityType,
8127
+ totalReviews: reviews.length,
8128
+ averageRating,
8129
+ recommendationRate,
8130
+ practitionerMetrics,
8131
+ procedureMetrics,
8132
+ comparisonToOverall: {
8133
+ ratingDifference: 0,
8134
+ // Will be calculated when comparing to overall
8135
+ recommendationDifference: 0
8136
+ }
8137
+ };
8138
+ return result;
8139
+ }
8140
+ /**
8141
+ * Gets overall review averages for comparison
8142
+ */
8143
+ async getOverallReviewAverages(dateRange, filters) {
8144
+ const reviews = await this.fetchReviews(dateRange, filters);
8145
+ const practitionerReviews = reviews.filter((r) => r.practitionerReview).map((r) => r.practitionerReview);
8146
+ const procedureReviews = reviews.filter((r) => r.procedureReview).map((r) => r.procedureReview);
8147
+ return {
8148
+ practitionerAverage: {
8149
+ totalReviews: practitionerReviews.length,
8150
+ averageRating: practitionerReviews.length > 0 ? this.calculateAverage(practitionerReviews.map((r) => r.overallRating)) : 0,
8151
+ recommendationRate: practitionerReviews.length > 0 ? practitionerReviews.filter((r) => r.wouldRecommend).length / practitionerReviews.length * 100 : 0,
8152
+ averageKnowledgeAndExpertise: this.calculateAverage(practitionerReviews.map((r) => r.knowledgeAndExpertise)),
8153
+ averageCommunicationSkills: this.calculateAverage(practitionerReviews.map((r) => r.communicationSkills)),
8154
+ averageBedSideManner: this.calculateAverage(practitionerReviews.map((r) => r.bedSideManner)),
8155
+ averageThoroughness: this.calculateAverage(practitionerReviews.map((r) => r.thoroughness)),
8156
+ averageTrustworthiness: this.calculateAverage(practitionerReviews.map((r) => r.trustworthiness))
8157
+ },
8158
+ procedureAverage: {
8159
+ totalReviews: procedureReviews.length,
8160
+ averageRating: procedureReviews.length > 0 ? this.calculateAverage(procedureReviews.map((r) => r.overallRating)) : 0,
8161
+ recommendationRate: procedureReviews.length > 0 ? procedureReviews.filter((r) => r.wouldRecommend).length / procedureReviews.length * 100 : 0,
8162
+ averageEffectiveness: this.calculateAverage(procedureReviews.map((r) => r.effectivenessOfTreatment)),
8163
+ averageOutcomeExplanation: this.calculateAverage(procedureReviews.map((r) => r.outcomeExplanation)),
8164
+ averagePainManagement: this.calculateAverage(procedureReviews.map((r) => r.painManagement)),
8165
+ averageFollowUpCare: this.calculateAverage(procedureReviews.map((r) => r.followUpCare)),
8166
+ averageValueForMoney: this.calculateAverage(procedureReviews.map((r) => r.valueForMoney))
8167
+ }
8168
+ };
8169
+ }
8170
+ /**
8171
+ * Gets review details for a specific entity
8172
+ */
8173
+ async getReviewDetails(entityType, entityId, dateRange, filters) {
8174
+ var _a, _b, _c;
8175
+ const reviews = await this.fetchReviews(dateRange, filters);
8176
+ let relevantReviews = [];
8177
+ if (entityType === "practitioner") {
8178
+ relevantReviews = reviews.filter((r) => {
8179
+ var _a2;
8180
+ return ((_a2 = r.practitionerReview) == null ? void 0 : _a2.practitionerId) === entityId;
8181
+ });
8182
+ } else if (entityType === "procedure") {
8183
+ relevantReviews = reviews.filter((r) => {
8184
+ var _a2;
8185
+ return ((_a2 = r.procedureReview) == null ? void 0 : _a2.procedureId) === entityId;
8186
+ });
8187
+ }
8188
+ const details = [];
8189
+ for (const review of relevantReviews) {
8190
+ try {
8191
+ const appointmentDocRef = (0, import_firestore3.doc)(this.db, APPOINTMENTS_COLLECTION, review.appointmentId);
8192
+ const appointmentDoc = await (0, import_firestore3.getDoc)(appointmentDocRef);
8193
+ let appointment = null;
8194
+ if (appointmentDoc.exists()) {
8195
+ appointment = appointmentDoc.data();
8196
+ }
8197
+ const createdAt = review.createdAt instanceof import_firestore3.Timestamp ? review.createdAt.toDate() : new Date(review.createdAt);
8198
+ const appointmentDate = (appointment == null ? void 0 : appointment.appointmentStartTime) ? appointment.appointmentStartTime instanceof import_firestore3.Timestamp ? appointment.appointmentStartTime.toDate() : appointment.appointmentStartTime : createdAt;
8199
+ details.push({
8200
+ reviewId: review.id,
8201
+ appointmentId: review.appointmentId,
8202
+ patientId: review.patientId,
8203
+ patientName: review.patientName || ((_a = appointment == null ? void 0 : appointment.patientInfo) == null ? void 0 : _a.fullName),
8204
+ createdAt,
8205
+ practitionerReview: review.practitionerReview,
8206
+ procedureReview: review.procedureReview,
8207
+ procedureName: (_b = appointment == null ? void 0 : appointment.procedureInfo) == null ? void 0 : _b.name,
8208
+ practitionerName: (_c = appointment == null ? void 0 : appointment.practitionerInfo) == null ? void 0 : _c.name,
8209
+ appointmentDate
8210
+ });
8211
+ } catch (error) {
8212
+ console.warn(`Failed to enhance review ${review.id}:`, error);
8213
+ }
8214
+ }
8215
+ return details;
8216
+ }
8217
+ /**
8218
+ * Helper method to calculate average
8219
+ */
8220
+ calculateAverage(values) {
8221
+ if (values.length === 0) return 0;
8222
+ const sum = values.reduce((acc, val) => acc + val, 0);
8223
+ return sum / values.length;
8224
+ }
8225
+ /**
8226
+ * Calculate review trends over time
8227
+ * Groups reviews by period and calculates rating and recommendation metrics
8228
+ *
8229
+ * @param dateRange - Date range for trend analysis (must align with period boundaries)
8230
+ * @param period - Period type (week, month, quarter, year)
8231
+ * @param filters - Optional filters for clinic, practitioner, procedure
8232
+ * @param entityType - Optional entity type to group trends by (practitioner, procedure, technology)
8233
+ * @returns Array of review trends with percentage changes
8234
+ */
8235
+ async getReviewTrends(dateRange, period, filters, entityType) {
8236
+ const reviews = await this.fetchReviews(dateRange, filters);
8237
+ if (reviews.length === 0) {
8238
+ return [];
8239
+ }
8240
+ if (entityType) {
8241
+ return this.getGroupedReviewTrends(reviews, dateRange, period, entityType, filters);
8242
+ }
8243
+ const periods = generatePeriods(dateRange.start, dateRange.end, period);
8244
+ const trends = [];
8245
+ let previousAvgRating = 0;
8246
+ let previousRecRate = 0;
8247
+ periods.forEach((periodInfo) => {
8248
+ const periodReviews = reviews.filter((review) => {
8249
+ const reviewDate = review.createdAt instanceof Date ? review.createdAt : review.createdAt.toDate();
8250
+ return reviewDate >= periodInfo.startDate && reviewDate <= periodInfo.endDate;
8251
+ });
8252
+ if (periodReviews.length === 0) {
8253
+ trends.push({
8254
+ period: periodInfo.period,
8255
+ startDate: periodInfo.startDate,
8256
+ endDate: periodInfo.endDate,
8257
+ averageRating: 0,
8258
+ recommendationRate: 0,
8259
+ totalReviews: 0,
8260
+ previousPeriod: void 0
8261
+ });
8262
+ previousAvgRating = 0;
8263
+ previousRecRate = 0;
8264
+ return;
8265
+ }
8266
+ let totalRatingSum = 0;
8267
+ let totalRatingCount = 0;
8268
+ let totalRecommendations = 0;
8269
+ let totalRecommendationCount = 0;
8270
+ periodReviews.forEach((review) => {
8271
+ if (review.practitionerReview) {
8272
+ totalRatingSum += review.practitionerReview.overallRating;
8273
+ totalRatingCount++;
8274
+ if (review.practitionerReview.wouldRecommend) {
8275
+ totalRecommendations++;
8276
+ }
8277
+ totalRecommendationCount++;
8278
+ }
8279
+ if (review.procedureReview) {
8280
+ totalRatingSum += review.procedureReview.overallRating;
8281
+ totalRatingCount++;
8282
+ if (review.procedureReview.wouldRecommend) {
8283
+ totalRecommendations++;
8284
+ }
8285
+ totalRecommendationCount++;
8286
+ }
8287
+ if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
8288
+ review.extendedProcedureReviews.forEach((extReview) => {
8289
+ totalRatingSum += extReview.overallRating;
8290
+ totalRatingCount++;
8291
+ if (extReview.wouldRecommend) {
8292
+ totalRecommendations++;
8293
+ }
8294
+ totalRecommendationCount++;
8295
+ });
8296
+ }
8297
+ });
8298
+ const currentAvgRating = totalRatingCount > 0 ? totalRatingSum / totalRatingCount : 0;
8299
+ const currentRecRate = totalRecommendationCount > 0 ? totalRecommendations / totalRecommendationCount * 100 : 0;
8300
+ const trendChange = getTrendChange(currentAvgRating, previousAvgRating);
8301
+ trends.push({
8302
+ period: periodInfo.period,
8303
+ startDate: periodInfo.startDate,
8304
+ endDate: periodInfo.endDate,
8305
+ averageRating: currentAvgRating,
8306
+ recommendationRate: currentRecRate,
8307
+ totalReviews: periodReviews.length,
8308
+ previousPeriod: previousAvgRating > 0 ? {
8309
+ averageRating: previousAvgRating,
8310
+ recommendationRate: previousRecRate,
8311
+ percentageChange: Math.abs(trendChange.percentageChange),
8312
+ direction: trendChange.direction
8313
+ } : void 0
8314
+ });
8315
+ previousAvgRating = currentAvgRating;
8316
+ previousRecRate = currentRecRate;
8317
+ });
8318
+ return trends;
8319
+ }
8320
+ /**
8321
+ * Calculate grouped review trends (by practitioner, procedure, or technology)
8322
+ * Returns the AVERAGE across all entities of that type for each period
8323
+ * @private
8324
+ */
8325
+ async getGroupedReviewTrends(reviews, dateRange, period, entityType, filters) {
8326
+ const periods = generatePeriods(dateRange.start, dateRange.end, period);
8327
+ const trends = [];
8328
+ let appointments = [];
8329
+ let procedureToTechnologyMap = /* @__PURE__ */ new Map();
8330
+ if (entityType === "technology" && this.appointmentService) {
8331
+ const searchParams = { ...filters };
8332
+ if (dateRange) {
8333
+ searchParams.startDate = dateRange.start;
8334
+ searchParams.endDate = dateRange.end;
8335
+ }
8336
+ const appointmentsResult = await this.appointmentService.searchAppointments(searchParams);
8337
+ appointments = appointmentsResult.appointments || [];
8338
+ appointments.forEach((appointment) => {
8339
+ var _a, _b;
8340
+ if (appointment.procedureId && ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId)) {
8341
+ procedureToTechnologyMap.set(appointment.procedureId, {
8342
+ id: appointment.procedureExtendedInfo.procedureTechnologyId,
8343
+ name: appointment.procedureExtendedInfo.procedureTechnologyName || "Unknown Technology"
8344
+ });
8345
+ }
8346
+ if ((_b = appointment.metadata) == null ? void 0 : _b.extendedProcedures) {
8347
+ appointment.metadata.extendedProcedures.forEach((extProc) => {
8348
+ if (extProc.procedureId && extProc.procedureTechnologyId) {
8349
+ procedureToTechnologyMap.set(extProc.procedureId, {
8350
+ id: extProc.procedureTechnologyId,
8351
+ name: extProc.procedureTechnologyName || "Unknown Technology"
8352
+ });
8353
+ }
8354
+ });
8355
+ }
8356
+ });
8357
+ }
8358
+ let previousAvgRating = 0;
8359
+ let previousRecRate = 0;
8360
+ periods.forEach((periodInfo) => {
8361
+ const periodReviews = reviews.filter((review) => {
8362
+ const reviewDate = review.createdAt instanceof Date ? review.createdAt : review.createdAt.toDate();
8363
+ return reviewDate >= periodInfo.startDate && reviewDate <= periodInfo.endDate;
8364
+ });
8365
+ if (periodReviews.length === 0) {
8366
+ trends.push({
8367
+ period: periodInfo.period,
8368
+ startDate: periodInfo.startDate,
8369
+ endDate: periodInfo.endDate,
8370
+ averageRating: 0,
8371
+ recommendationRate: 0,
8372
+ totalReviews: 0,
8373
+ previousPeriod: void 0
8374
+ });
8375
+ previousAvgRating = 0;
8376
+ previousRecRate = 0;
8377
+ return;
8378
+ }
8379
+ let totalRatingSum = 0;
8380
+ let totalRatingCount = 0;
8381
+ let totalRecommendations = 0;
8382
+ let totalRecommendationCount = 0;
8383
+ periodReviews.forEach((review) => {
8384
+ var _a;
8385
+ if (entityType === "practitioner" && review.practitionerReview) {
8386
+ totalRatingSum += review.practitionerReview.overallRating;
8387
+ totalRatingCount++;
8388
+ if (review.practitionerReview.wouldRecommend) {
8389
+ totalRecommendations++;
8390
+ }
8391
+ totalRecommendationCount++;
8392
+ } else if (entityType === "procedure" && review.procedureReview) {
8393
+ totalRatingSum += review.procedureReview.overallRating;
8394
+ totalRatingCount++;
8395
+ if (review.procedureReview.wouldRecommend) {
8396
+ totalRecommendations++;
8397
+ }
8398
+ totalRecommendationCount++;
8399
+ if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
8400
+ review.extendedProcedureReviews.forEach((extReview) => {
8401
+ totalRatingSum += extReview.overallRating;
8402
+ totalRatingCount++;
8403
+ if (extReview.wouldRecommend) {
8404
+ totalRecommendations++;
8405
+ }
8406
+ totalRecommendationCount++;
8407
+ });
8408
+ }
8409
+ } else if (entityType === "technology") {
8410
+ if ((_a = review.procedureReview) == null ? void 0 : _a.procedureId) {
8411
+ const tech = procedureToTechnologyMap.get(review.procedureReview.procedureId);
8412
+ if (tech) {
8413
+ totalRatingSum += review.procedureReview.overallRating;
8414
+ totalRatingCount++;
8415
+ if (review.procedureReview.wouldRecommend) {
8416
+ totalRecommendations++;
8417
+ }
8418
+ totalRecommendationCount++;
8419
+ }
8420
+ }
8421
+ if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
8422
+ review.extendedProcedureReviews.forEach((extReview) => {
8423
+ if (extReview.procedureId) {
8424
+ const tech = procedureToTechnologyMap.get(extReview.procedureId);
8425
+ if (tech) {
8426
+ totalRatingSum += extReview.overallRating;
8427
+ totalRatingCount++;
8428
+ if (extReview.wouldRecommend) {
8429
+ totalRecommendations++;
8430
+ }
8431
+ totalRecommendationCount++;
8432
+ }
8433
+ }
8434
+ });
8435
+ }
8436
+ }
8437
+ });
8438
+ const currentAvgRating = totalRatingCount > 0 ? totalRatingSum / totalRatingCount : 0;
8439
+ const currentRecRate = totalRecommendationCount > 0 ? totalRecommendations / totalRecommendationCount * 100 : 0;
8440
+ const trendChange = getTrendChange(currentAvgRating, previousAvgRating);
8441
+ trends.push({
8442
+ period: periodInfo.period,
8443
+ startDate: periodInfo.startDate,
8444
+ endDate: periodInfo.endDate,
8445
+ averageRating: currentAvgRating,
8446
+ recommendationRate: currentRecRate,
8447
+ totalReviews: totalRatingCount,
8448
+ // Count of reviews for this entity type
8449
+ previousPeriod: previousAvgRating > 0 ? {
8450
+ averageRating: previousAvgRating,
8451
+ recommendationRate: previousRecRate,
8452
+ percentageChange: Math.abs(trendChange.percentageChange),
8453
+ direction: trendChange.direction
8454
+ } : void 0
8455
+ });
8456
+ previousAvgRating = currentAvgRating;
8457
+ previousRecRate = currentRecRate;
8458
+ });
8459
+ return trends;
8460
+ }
8461
+ };
8462
+
8463
+ // src/services/analytics/analytics.service.ts
8464
+ var AnalyticsService = class extends BaseService {
8465
+ /**
8466
+ * Creates a new AnalyticsService instance.
8467
+ *
8468
+ * @param db Firestore instance
8469
+ * @param auth Firebase Auth instance
8470
+ * @param app Firebase App instance
8471
+ * @param appointmentService Appointment service instance for querying appointments
8472
+ */
8473
+ constructor(db, auth, app, appointmentService) {
8474
+ super(db, auth, app);
8475
+ this.appointmentService = appointmentService;
8476
+ this.reviewAnalyticsService = new ReviewAnalyticsService(db, auth, app, appointmentService);
8477
+ }
8478
+ /**
8479
+ * Fetches appointments with optional filters
8480
+ *
8481
+ * @param filters - Optional filters
8482
+ * @param dateRange - Optional date range
8483
+ * @returns Array of appointments
8484
+ */
8485
+ async fetchAppointments(filters, dateRange) {
8486
+ try {
8487
+ const constraints = [];
8488
+ if (filters == null ? void 0 : filters.clinicBranchId) {
8489
+ constraints.push((0, import_firestore4.where)("clinicBranchId", "==", filters.clinicBranchId));
8490
+ }
8491
+ if (filters == null ? void 0 : filters.practitionerId) {
8492
+ constraints.push((0, import_firestore4.where)("practitionerId", "==", filters.practitionerId));
8493
+ }
8494
+ if (filters == null ? void 0 : filters.procedureId) {
8495
+ constraints.push((0, import_firestore4.where)("procedureId", "==", filters.procedureId));
8496
+ }
8497
+ if (filters == null ? void 0 : filters.patientId) {
8498
+ constraints.push((0, import_firestore4.where)("patientId", "==", filters.patientId));
8499
+ }
8500
+ if (dateRange) {
8501
+ constraints.push((0, import_firestore4.where)("appointmentStartTime", ">=", import_firestore4.Timestamp.fromDate(dateRange.start)));
8502
+ constraints.push((0, import_firestore4.where)("appointmentStartTime", "<=", import_firestore4.Timestamp.fromDate(dateRange.end)));
8503
+ }
8504
+ const searchParams = {};
8505
+ if (filters == null ? void 0 : filters.clinicBranchId) searchParams.clinicBranchId = filters.clinicBranchId;
8506
+ if (filters == null ? void 0 : filters.practitionerId) searchParams.practitionerId = filters.practitionerId;
8507
+ if (filters == null ? void 0 : filters.procedureId) searchParams.procedureId = filters.procedureId;
8508
+ if (filters == null ? void 0 : filters.patientId) searchParams.patientId = filters.patientId;
8509
+ if (dateRange) {
8510
+ searchParams.startDate = dateRange.start;
8511
+ searchParams.endDate = dateRange.end;
8512
+ }
8513
+ const result = await this.appointmentService.searchAppointments(searchParams);
8514
+ return result.appointments;
8515
+ } catch (error) {
8516
+ console.error("[AnalyticsService] Error fetching appointments:", error);
8517
+ throw error;
8518
+ }
8519
+ }
8520
+ // ==========================================
8521
+ // Practitioner Analytics
8522
+ // ==========================================
8523
+ /**
8524
+ * Get practitioner performance metrics
8525
+ * First checks for stored analytics, then calculates if not available or stale
8526
+ *
8527
+ * @param practitionerId - ID of the practitioner
8528
+ * @param dateRange - Optional date range filter
8529
+ * @param options - Options for reading stored analytics
8530
+ * @returns Practitioner analytics object
8531
+ */
8532
+ async getPractitionerAnalytics(practitionerId, dateRange, options) {
8533
+ var _a;
8534
+ if (dateRange && (options == null ? void 0 : options.useCache) !== false) {
8535
+ const period = this.determinePeriodFromDateRange(dateRange);
8536
+ const clinicBranchId = options == null ? void 0 : options.clinicBranchId;
8537
+ if (clinicBranchId) {
8538
+ const stored = await readStoredPractitionerAnalytics(
8539
+ this.db,
8540
+ clinicBranchId,
8541
+ practitionerId,
8542
+ { ...options, period }
8543
+ );
8544
+ if (stored) {
8545
+ const { metadata, ...analytics } = stored;
8546
+ return analytics;
8547
+ }
8548
+ }
8549
+ }
8550
+ const appointments = await this.fetchAppointments({ practitionerId }, dateRange);
8551
+ const completed = getCompletedAppointments(appointments);
8552
+ const canceled = getCanceledAppointments(appointments);
8553
+ const noShow = getNoShowAppointments(appointments);
8554
+ const pending = filterAppointments(appointments, { practitionerId }).filter(
8555
+ (a) => a.status === "pending" /* PENDING */
8556
+ );
8557
+ const confirmed = filterAppointments(appointments, { practitionerId }).filter(
8558
+ (a) => a.status === "confirmed" /* CONFIRMED */
8559
+ );
8560
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
8561
+ const timeMetrics = calculateAverageTimeMetrics(completed);
8562
+ const uniquePatients = new Set(appointments.map((a) => a.patientId));
8563
+ const returningPatients = new Set(
8564
+ appointments.filter((a) => {
8565
+ const patientAppointments = appointments.filter((ap) => ap.patientId === a.patientId);
8566
+ return patientAppointments.length > 1;
8567
+ }).map((a) => a.patientId)
8568
+ );
8569
+ const procedureMap = /* @__PURE__ */ new Map();
8570
+ completed.forEach((appointment) => {
8571
+ var _a2;
8572
+ const procId = appointment.procedureId;
8573
+ const procName = ((_a2 = appointment.procedureInfo) == null ? void 0 : _a2.name) || "Unknown";
8574
+ const cost = calculateAppointmentCost(appointment).cost;
8575
+ if (procedureMap.has(procId)) {
8576
+ const existing = procedureMap.get(procId);
8577
+ existing.count++;
8578
+ existing.revenue += cost;
8579
+ } else {
8580
+ procedureMap.set(procId, { name: procName, count: 1, revenue: cost });
8581
+ }
8582
+ });
8583
+ const topProcedures = Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
8584
+ procedureId,
8585
+ procedureName: data.name,
8586
+ count: data.count,
8587
+ revenue: data.revenue
8588
+ })).sort((a, b) => b.count - a.count).slice(0, 10);
8589
+ const practitionerName = appointments.length > 0 ? ((_a = appointments[0].practitionerInfo) == null ? void 0 : _a.name) || "Unknown" : "Unknown";
8590
+ return {
8591
+ total: appointments.length,
8592
+ dateRange,
8593
+ practitionerId,
8594
+ practitionerName,
8595
+ totalAppointments: appointments.length,
8596
+ completedAppointments: completed.length,
8597
+ canceledAppointments: canceled.length,
8598
+ noShowAppointments: noShow.length,
8599
+ pendingAppointments: pending.length,
8600
+ confirmedAppointments: confirmed.length,
8601
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
8602
+ noShowRate: calculatePercentage(noShow.length, appointments.length),
8603
+ averageBookedTime: timeMetrics.averageBookedDuration,
8604
+ averageActualTime: timeMetrics.averageActualDuration,
8605
+ timeEfficiency: timeMetrics.averageEfficiency,
8606
+ totalRevenue,
8607
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
8608
+ currency,
8609
+ topProcedures,
8610
+ patientRetentionRate: calculatePercentage(returningPatients.size, uniquePatients.size),
8611
+ uniquePatients: uniquePatients.size
8612
+ };
8613
+ }
8614
+ // ==========================================
8615
+ // Procedure Analytics
8616
+ // ==========================================
8617
+ /**
8618
+ * Get procedure performance metrics
8619
+ * First checks for stored analytics, then calculates if not available or stale
8620
+ *
8621
+ * @param procedureId - ID of the procedure (optional, if not provided returns all)
8622
+ * @param dateRange - Optional date range filter
8623
+ * @param options - Options for reading stored analytics
8624
+ * @returns Procedure analytics object or array
8625
+ */
8626
+ async getProcedureAnalytics(procedureId, dateRange, options) {
8627
+ if (procedureId && dateRange && (options == null ? void 0 : options.useCache) !== false) {
8628
+ const period = this.determinePeriodFromDateRange(dateRange);
8629
+ const clinicBranchId = options == null ? void 0 : options.clinicBranchId;
8630
+ if (clinicBranchId) {
8631
+ const stored = await readStoredProcedureAnalytics(
8632
+ this.db,
8633
+ clinicBranchId,
8634
+ procedureId,
8635
+ { ...options, period }
8636
+ );
8637
+ if (stored) {
8638
+ const { metadata, ...analytics } = stored;
8639
+ return analytics;
8640
+ }
8641
+ }
8642
+ }
8643
+ const appointments = await this.fetchAppointments(procedureId ? { procedureId } : void 0, dateRange);
8644
+ if (procedureId) {
8645
+ return this.calculateProcedureAnalytics(appointments, procedureId);
8646
+ }
8647
+ const procedureMap = /* @__PURE__ */ new Map();
8648
+ appointments.forEach((appointment) => {
8649
+ const procId = appointment.procedureId;
8650
+ if (!procedureMap.has(procId)) {
8651
+ procedureMap.set(procId, []);
8652
+ }
8653
+ procedureMap.get(procId).push(appointment);
8654
+ });
8655
+ return Array.from(procedureMap.entries()).map(
8656
+ ([procId, procAppointments]) => this.calculateProcedureAnalytics(procAppointments, procId)
8657
+ );
8658
+ }
8659
+ /**
8660
+ * Calculate analytics for a specific procedure
8661
+ *
8662
+ * @param appointments - Appointments for the procedure
8663
+ * @param procedureId - Procedure ID
8664
+ * @returns Procedure analytics
8665
+ */
8666
+ calculateProcedureAnalytics(appointments, procedureId) {
8667
+ const completed = getCompletedAppointments(appointments);
8668
+ const canceled = getCanceledAppointments(appointments);
8669
+ const noShow = getNoShowAppointments(appointments);
8670
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
8671
+ const timeMetrics = calculateAverageTimeMetrics(completed);
8672
+ const firstAppointment = appointments[0];
8673
+ const procedureInfo = (firstAppointment == null ? void 0 : firstAppointment.procedureExtendedInfo) || (firstAppointment == null ? void 0 : firstAppointment.procedureInfo);
8674
+ const productMap = /* @__PURE__ */ new Map();
8675
+ completed.forEach((appointment) => {
8676
+ const products = extractProductUsage(appointment);
8677
+ products.forEach((product) => {
8678
+ if (productMap.has(product.productId)) {
8679
+ const existing = productMap.get(product.productId);
8680
+ existing.quantity += product.quantity;
8681
+ existing.revenue += product.subtotal;
8682
+ existing.usageCount++;
8683
+ } else {
8684
+ productMap.set(product.productId, {
8685
+ name: product.productName,
8686
+ brandName: product.brandName,
8687
+ quantity: product.quantity,
8688
+ revenue: product.subtotal,
8689
+ usageCount: 1
8690
+ });
8691
+ }
8692
+ });
8693
+ });
8694
+ const productUsage = Array.from(productMap.entries()).map(([productId, data]) => ({
8695
+ productId,
8696
+ productName: data.name,
8697
+ brandName: data.brandName,
8698
+ totalQuantity: data.quantity,
8699
+ totalRevenue: data.revenue,
8700
+ usageCount: data.usageCount
8701
+ }));
8702
+ return {
8703
+ total: appointments.length,
8704
+ procedureId,
8705
+ procedureName: (procedureInfo == null ? void 0 : procedureInfo.name) || "Unknown",
8706
+ procedureFamily: (procedureInfo == null ? void 0 : procedureInfo.procedureFamily) || "",
8707
+ categoryName: (procedureInfo == null ? void 0 : procedureInfo.procedureCategoryName) || "",
8708
+ subcategoryName: (procedureInfo == null ? void 0 : procedureInfo.procedureSubCategoryName) || "",
8709
+ technologyName: (procedureInfo == null ? void 0 : procedureInfo.procedureTechnologyName) || "",
8710
+ totalAppointments: appointments.length,
8711
+ completedAppointments: completed.length,
8712
+ canceledAppointments: canceled.length,
8713
+ noShowAppointments: noShow.length,
8714
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
8715
+ noShowRate: calculatePercentage(noShow.length, appointments.length),
8716
+ averageCost: completed.length > 0 ? totalRevenue / completed.length : 0,
8717
+ totalRevenue,
8718
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
8719
+ currency,
8720
+ averageBookedDuration: timeMetrics.averageBookedDuration,
8721
+ averageActualDuration: timeMetrics.averageActualDuration,
8722
+ productUsage
8723
+ };
8724
+ }
8725
+ /**
8726
+ * Get procedure popularity metrics
8727
+ *
8728
+ * @param dateRange - Optional date range filter
8729
+ * @param limit - Number of top procedures to return
8730
+ * @returns Array of procedure popularity metrics
8731
+ */
8732
+ async getProcedurePopularity(dateRange, limit = 10) {
8733
+ const appointments = await this.fetchAppointments(void 0, dateRange);
8734
+ const completed = getCompletedAppointments(appointments);
8735
+ const procedureMap = /* @__PURE__ */ new Map();
8736
+ completed.forEach((appointment) => {
8737
+ const procId = appointment.procedureId;
8738
+ const procInfo = appointment.procedureExtendedInfo || appointment.procedureInfo;
8739
+ if (procedureMap.has(procId)) {
8740
+ procedureMap.get(procId).count++;
8741
+ } else {
8742
+ procedureMap.set(procId, {
8743
+ name: (procInfo == null ? void 0 : procInfo.name) || "Unknown",
8744
+ category: (procInfo == null ? void 0 : procInfo.procedureCategoryName) || "",
8745
+ subcategory: (procInfo == null ? void 0 : procInfo.procedureSubCategoryName) || "",
8746
+ technology: (procInfo == null ? void 0 : procInfo.procedureTechnologyName) || "",
8747
+ count: 1
8748
+ });
8749
+ }
8750
+ });
8751
+ return Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
8752
+ procedureId,
8753
+ procedureName: data.name,
8754
+ categoryName: data.category,
8755
+ subcategoryName: data.subcategory,
8756
+ technologyName: data.technology,
8757
+ appointmentCount: data.count,
8758
+ completedCount: data.count,
8759
+ rank: 0
8760
+ // Will be set after sorting
8761
+ })).sort((a, b) => b.appointmentCount - a.appointmentCount).slice(0, limit).map((item, index) => ({ ...item, rank: index + 1 }));
8762
+ }
8763
+ /**
8764
+ * Get procedure profitability metrics
8765
+ *
8766
+ * @param dateRange - Optional date range filter
8767
+ * @param limit - Number of top procedures to return
8768
+ * @returns Array of procedure profitability metrics
8769
+ */
8770
+ async getProcedureProfitability(dateRange, limit = 10) {
8771
+ const appointments = await this.fetchAppointments(void 0, dateRange);
8772
+ const completed = getCompletedAppointments(appointments);
8773
+ const procedureMap = /* @__PURE__ */ new Map();
8774
+ completed.forEach((appointment) => {
8775
+ const procId = appointment.procedureId;
8776
+ const procInfo = appointment.procedureExtendedInfo || appointment.procedureInfo;
8777
+ const cost = calculateAppointmentCost(appointment).cost;
8778
+ if (procedureMap.has(procId)) {
8779
+ const existing = procedureMap.get(procId);
8780
+ existing.revenue += cost;
8781
+ existing.count++;
8782
+ } else {
8783
+ procedureMap.set(procId, {
8784
+ name: (procInfo == null ? void 0 : procInfo.name) || "Unknown",
8785
+ category: (procInfo == null ? void 0 : procInfo.procedureCategoryName) || "",
8786
+ subcategory: (procInfo == null ? void 0 : procInfo.procedureSubCategoryName) || "",
8787
+ technology: (procInfo == null ? void 0 : procInfo.procedureTechnologyName) || "",
8788
+ revenue: cost,
8789
+ count: 1
8790
+ });
8791
+ }
8792
+ });
8793
+ return Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
8794
+ procedureId,
8795
+ procedureName: data.name,
8796
+ categoryName: data.category,
8797
+ subcategoryName: data.subcategory,
8798
+ technologyName: data.technology,
8799
+ totalRevenue: data.revenue,
8800
+ averageRevenue: data.count > 0 ? data.revenue / data.count : 0,
8801
+ appointmentCount: data.count,
8802
+ rank: 0
8803
+ // Will be set after sorting
8804
+ })).sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, limit).map((item, index) => ({ ...item, rank: index + 1 }));
8805
+ }
8806
+ // ==========================================
8807
+ // Time Efficiency Analytics
8808
+ // ==========================================
8809
+ /**
8810
+ * Get time efficiency metrics grouped by clinic, practitioner, procedure, patient, or technology
8811
+ *
8812
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient' | 'technology'
8813
+ * @param dateRange - Optional date range filter
8814
+ * @param filters - Optional additional filters
8815
+ * @returns Grouped time efficiency metrics
8816
+ */
8817
+ async getTimeEfficiencyMetricsByEntity(groupBy, dateRange, filters) {
8818
+ const appointments = await this.fetchAppointments(filters, dateRange);
8819
+ return calculateGroupedTimeEfficiencyMetrics(appointments, groupBy);
8820
+ }
8821
+ /**
8822
+ * Get time efficiency metrics for appointments
8823
+ * First checks for stored analytics, then calculates if not available or stale
8824
+ *
8825
+ * @param filters - Optional filters
8826
+ * @param dateRange - Optional date range filter
8827
+ * @param options - Options for reading stored analytics
8828
+ * @returns Time efficiency metrics
8829
+ */
8830
+ async getTimeEfficiencyMetrics(filters, dateRange, options) {
8831
+ if ((filters == null ? void 0 : filters.clinicBranchId) && dateRange && (options == null ? void 0 : options.useCache) !== false) {
8832
+ const period = this.determinePeriodFromDateRange(dateRange);
8833
+ const stored = await readStoredTimeEfficiencyMetrics(
8834
+ this.db,
8835
+ filters.clinicBranchId,
8836
+ { ...options, period }
8837
+ );
8838
+ if (stored) {
8839
+ const { metadata, ...metrics } = stored;
8840
+ return metrics;
8841
+ }
8842
+ }
8843
+ const appointments = await this.fetchAppointments(filters, dateRange);
8844
+ const completed = getCompletedAppointments(appointments);
8845
+ const timeMetrics = calculateAverageTimeMetrics(completed);
8846
+ const efficiencyDistribution = calculateEfficiencyDistribution(completed);
8847
+ return {
8848
+ totalAppointments: completed.length,
8849
+ appointmentsWithActualTime: timeMetrics.appointmentsWithActualTime,
8850
+ averageBookedDuration: timeMetrics.averageBookedDuration,
8851
+ averageActualDuration: timeMetrics.averageActualDuration,
8852
+ averageEfficiency: timeMetrics.averageEfficiency,
8853
+ totalOverrun: timeMetrics.totalOverrun,
8854
+ totalUnderutilization: timeMetrics.totalUnderutilization,
8855
+ averageOverrun: timeMetrics.averageOverrun,
8856
+ averageUnderutilization: timeMetrics.averageUnderutilization,
8857
+ efficiencyDistribution
8858
+ };
8859
+ }
8860
+ // ==========================================
8861
+ // Cancellation & No-Show Analytics
8862
+ // ==========================================
8863
+ /**
8864
+ * Get cancellation metrics
8865
+ * First checks for stored analytics when grouping by clinic, then calculates if not available or stale
8866
+ *
8867
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'patient' | 'procedure' | 'technology'
8868
+ * @param dateRange - Optional date range filter
8869
+ * @param options - Options for reading stored analytics (requires clinicBranchId for cache)
8870
+ * @returns Cancellation metrics grouped by specified entity
8871
+ */
8872
+ async getCancellationMetrics(groupBy, dateRange, options) {
8873
+ if (groupBy === "clinic" && dateRange && (options == null ? void 0 : options.useCache) !== false && (options == null ? void 0 : options.clinicBranchId)) {
8874
+ const period = this.determinePeriodFromDateRange(dateRange);
8875
+ const stored = await readStoredCancellationMetrics(
8876
+ this.db,
8877
+ options.clinicBranchId,
8878
+ "clinic",
8879
+ { ...options, period }
8880
+ );
8881
+ if (stored) {
8882
+ const { metadata, ...metrics } = stored;
8883
+ return metrics;
8884
+ }
8885
+ }
8886
+ const appointments = await this.fetchAppointments(void 0, dateRange);
8887
+ const canceled = getCanceledAppointments(appointments);
8888
+ if (groupBy === "clinic") {
8889
+ return this.groupCancellationsByClinic(canceled, appointments);
8890
+ } else if (groupBy === "practitioner") {
8891
+ return this.groupCancellationsByPractitioner(canceled, appointments);
8892
+ } else if (groupBy === "patient") {
8893
+ return this.groupCancellationsByPatient(canceled, appointments);
8894
+ } else if (groupBy === "technology") {
8895
+ return this.groupCancellationsByTechnology(canceled, appointments);
8896
+ } else {
8897
+ return this.groupCancellationsByProcedure(canceled, appointments);
8898
+ }
8899
+ }
8900
+ /**
8901
+ * Group cancellations by clinic
8902
+ */
8903
+ groupCancellationsByClinic(canceled, allAppointments) {
8904
+ const clinicMap = /* @__PURE__ */ new Map();
8905
+ allAppointments.forEach((appointment) => {
8906
+ var _a;
8907
+ const clinicId = appointment.clinicBranchId;
8908
+ const clinicName = ((_a = appointment.clinicInfo) == null ? void 0 : _a.name) || "Unknown";
8909
+ if (!clinicMap.has(clinicId)) {
8910
+ clinicMap.set(clinicId, { name: clinicName, canceled: [], all: [] });
8911
+ }
8912
+ clinicMap.get(clinicId).all.push(appointment);
8913
+ });
8914
+ canceled.forEach((appointment) => {
8915
+ const clinicId = appointment.clinicBranchId;
8916
+ if (clinicMap.has(clinicId)) {
8917
+ clinicMap.get(clinicId).canceled.push(appointment);
8918
+ }
8919
+ });
8920
+ return Array.from(clinicMap.entries()).map(
8921
+ ([clinicId, data]) => this.calculateCancellationMetrics(clinicId, data.name, "clinic", data.canceled, data.all)
8922
+ );
8923
+ }
8924
+ /**
8925
+ * Group cancellations by practitioner
8926
+ */
8927
+ groupCancellationsByPractitioner(canceled, allAppointments) {
8928
+ const practitionerMap = /* @__PURE__ */ new Map();
8929
+ allAppointments.forEach((appointment) => {
8930
+ var _a;
8931
+ const practitionerId = appointment.practitionerId;
8932
+ const practitionerName = ((_a = appointment.practitionerInfo) == null ? void 0 : _a.name) || "Unknown";
8933
+ if (!practitionerMap.has(practitionerId)) {
8934
+ practitionerMap.set(practitionerId, { name: practitionerName, canceled: [], all: [] });
8935
+ }
8936
+ practitionerMap.get(practitionerId).all.push(appointment);
8937
+ });
8938
+ canceled.forEach((appointment) => {
8939
+ const practitionerId = appointment.practitionerId;
8940
+ if (practitionerMap.has(practitionerId)) {
8941
+ practitionerMap.get(practitionerId).canceled.push(appointment);
8942
+ }
8943
+ });
8944
+ return Array.from(practitionerMap.entries()).map(
8945
+ ([practitionerId, data]) => this.calculateCancellationMetrics(
8946
+ practitionerId,
8947
+ data.name,
8948
+ "practitioner",
8949
+ data.canceled,
8950
+ data.all
8951
+ )
8952
+ );
8953
+ }
8954
+ /**
8955
+ * Group cancellations by patient
8956
+ */
8957
+ groupCancellationsByPatient(canceled, allAppointments) {
8958
+ const patientMap = /* @__PURE__ */ new Map();
8959
+ allAppointments.forEach((appointment) => {
8960
+ var _a;
8961
+ const patientId = appointment.patientId;
8962
+ const patientName = ((_a = appointment.patientInfo) == null ? void 0 : _a.fullName) || "Unknown";
8963
+ if (!patientMap.has(patientId)) {
8964
+ patientMap.set(patientId, { name: patientName, canceled: [], all: [] });
8965
+ }
8966
+ patientMap.get(patientId).all.push(appointment);
8967
+ });
8968
+ canceled.forEach((appointment) => {
8969
+ const patientId = appointment.patientId;
8970
+ if (patientMap.has(patientId)) {
8971
+ patientMap.get(patientId).canceled.push(appointment);
8972
+ }
8973
+ });
8974
+ return Array.from(patientMap.entries()).map(
8975
+ ([patientId, data]) => this.calculateCancellationMetrics(patientId, data.name, "patient", data.canceled, data.all)
8976
+ );
8977
+ }
8978
+ /**
8979
+ * Group cancellations by procedure
8980
+ */
8981
+ groupCancellationsByProcedure(canceled, allAppointments) {
8982
+ const procedureMap = /* @__PURE__ */ new Map();
8983
+ allAppointments.forEach((appointment) => {
8984
+ var _a, _b;
8985
+ const procedureId = appointment.procedureId;
8986
+ const procedureName = ((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown";
8987
+ if (!procedureMap.has(procedureId)) {
8988
+ procedureMap.set(procedureId, {
8989
+ name: procedureName,
8990
+ canceled: [],
8991
+ all: [],
8992
+ practitionerId: appointment.practitionerId,
8993
+ practitionerName: (_b = appointment.practitionerInfo) == null ? void 0 : _b.name
8994
+ });
8995
+ }
8996
+ procedureMap.get(procedureId).all.push(appointment);
8997
+ });
8998
+ canceled.forEach((appointment) => {
8999
+ const procedureId = appointment.procedureId;
9000
+ if (procedureMap.has(procedureId)) {
9001
+ procedureMap.get(procedureId).canceled.push(appointment);
9002
+ }
9003
+ });
9004
+ return Array.from(procedureMap.entries()).map(([procedureId, data]) => {
9005
+ const metrics = this.calculateCancellationMetrics(
9006
+ procedureId,
9007
+ data.name,
9008
+ "procedure",
9009
+ data.canceled,
9010
+ data.all
9011
+ );
9012
+ return {
9013
+ ...metrics,
9014
+ ...data.practitionerId && { practitionerId: data.practitionerId },
9015
+ ...data.practitionerName && { practitionerName: data.practitionerName }
9016
+ };
9017
+ });
9018
+ }
9019
+ /**
9020
+ * Group cancellations by technology
9021
+ * Aggregates all procedures using the same technology across all doctors
9022
+ */
9023
+ groupCancellationsByTechnology(canceled, allAppointments) {
9024
+ const technologyMap = /* @__PURE__ */ new Map();
9025
+ allAppointments.forEach((appointment) => {
9026
+ var _a, _b, _c;
9027
+ const technologyId = ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
9028
+ const technologyName = ((_b = appointment.procedureExtendedInfo) == null ? void 0 : _b.procedureTechnologyName) || ((_c = appointment.procedureInfo) == null ? void 0 : _c.technologyName) || "Unknown";
9029
+ if (!technologyMap.has(technologyId)) {
9030
+ technologyMap.set(technologyId, { name: technologyName, canceled: [], all: [] });
9031
+ }
9032
+ technologyMap.get(technologyId).all.push(appointment);
9033
+ });
9034
+ canceled.forEach((appointment) => {
9035
+ var _a;
9036
+ const technologyId = ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
9037
+ if (technologyMap.has(technologyId)) {
9038
+ technologyMap.get(technologyId).canceled.push(appointment);
9039
+ }
9040
+ });
9041
+ return Array.from(technologyMap.entries()).map(
9042
+ ([technologyId, data]) => this.calculateCancellationMetrics(
9043
+ technologyId,
9044
+ data.name,
9045
+ "technology",
9046
+ data.canceled,
9047
+ data.all
9048
+ )
9049
+ );
9050
+ }
9051
+ /**
9052
+ * Calculate cancellation metrics for a specific entity
9053
+ */
9054
+ calculateCancellationMetrics(entityId, entityName, entityType, canceled, all) {
9055
+ const canceledByPatient = canceled.filter(
9056
+ (a) => a.status === "canceled_patient" /* CANCELED_PATIENT */
9057
+ ).length;
9058
+ const canceledByClinic = canceled.filter(
9059
+ (a) => a.status === "canceled_clinic" /* CANCELED_CLINIC */
9060
+ ).length;
9061
+ const canceledRescheduled = canceled.filter(
9062
+ (a) => a.status === "canceled_patient_rescheduled" /* CANCELED_PATIENT_RESCHEDULED */
9063
+ ).length;
9064
+ const leadTimes = canceled.map((a) => calculateCancellationLeadTime(a)).filter((lt) => lt !== null);
9065
+ const averageLeadTime = leadTimes.length > 0 ? leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length : 0;
9066
+ const reasonMap = /* @__PURE__ */ new Map();
9067
+ canceled.forEach((appointment) => {
9068
+ const reason = appointment.cancellationReason || "No reason provided";
9069
+ reasonMap.set(reason, (reasonMap.get(reason) || 0) + 1);
9070
+ });
9071
+ const cancellationReasons = Array.from(reasonMap.entries()).map(([reason, count]) => ({
9072
+ reason,
9073
+ count,
9074
+ percentage: calculatePercentage(count, canceled.length)
9075
+ }));
9076
+ return {
9077
+ entityId,
9078
+ entityName,
9079
+ entityType,
9080
+ totalAppointments: all.length,
9081
+ canceledAppointments: canceled.length,
9082
+ cancellationRate: calculatePercentage(canceled.length, all.length),
9083
+ canceledByPatient,
9084
+ canceledByClinic,
9085
+ canceledByPractitioner: 0,
9086
+ // Not tracked in current status enum
9087
+ canceledRescheduled,
9088
+ averageCancellationLeadTime: Math.round(averageLeadTime * 100) / 100,
9089
+ cancellationReasons
9090
+ };
9091
+ }
9092
+ /**
9093
+ * Get no-show metrics
9094
+ * First checks for stored analytics when grouping by clinic, then calculates if not available or stale
9095
+ *
9096
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'patient' | 'procedure' | 'technology'
9097
+ * @param dateRange - Optional date range filter
9098
+ * @param options - Options for reading stored analytics (requires clinicBranchId for cache)
9099
+ * @returns No-show metrics grouped by specified entity
9100
+ */
9101
+ async getNoShowMetrics(groupBy, dateRange, options) {
9102
+ if (groupBy === "clinic" && dateRange && (options == null ? void 0 : options.useCache) !== false && (options == null ? void 0 : options.clinicBranchId)) {
9103
+ const period = this.determinePeriodFromDateRange(dateRange);
9104
+ const stored = await readStoredNoShowMetrics(
9105
+ this.db,
9106
+ options.clinicBranchId,
9107
+ "clinic",
9108
+ { ...options, period }
9109
+ );
9110
+ if (stored) {
9111
+ const { metadata, ...metrics } = stored;
9112
+ return metrics;
9113
+ }
9114
+ }
9115
+ const appointments = await this.fetchAppointments(void 0, dateRange);
9116
+ const noShow = getNoShowAppointments(appointments);
9117
+ if (groupBy === "clinic") {
9118
+ return this.groupNoShowsByClinic(noShow, appointments);
9119
+ } else if (groupBy === "practitioner") {
9120
+ return this.groupNoShowsByPractitioner(noShow, appointments);
9121
+ } else if (groupBy === "patient") {
9122
+ return this.groupNoShowsByPatient(noShow, appointments);
9123
+ } else if (groupBy === "technology") {
9124
+ return this.groupNoShowsByTechnology(noShow, appointments);
9125
+ } else {
9126
+ return this.groupNoShowsByProcedure(noShow, appointments);
9127
+ }
9128
+ }
9129
+ /**
9130
+ * Group no-shows by clinic
9131
+ */
9132
+ groupNoShowsByClinic(noShow, allAppointments) {
9133
+ const clinicMap = /* @__PURE__ */ new Map();
9134
+ allAppointments.forEach((appointment) => {
9135
+ var _a;
9136
+ const clinicId = appointment.clinicBranchId;
9137
+ const clinicName = ((_a = appointment.clinicInfo) == null ? void 0 : _a.name) || "Unknown";
9138
+ if (!clinicMap.has(clinicId)) {
9139
+ clinicMap.set(clinicId, { name: clinicName, noShow: [], all: [] });
9140
+ }
9141
+ clinicMap.get(clinicId).all.push(appointment);
9142
+ });
9143
+ noShow.forEach((appointment) => {
9144
+ const clinicId = appointment.clinicBranchId;
9145
+ if (clinicMap.has(clinicId)) {
9146
+ clinicMap.get(clinicId).noShow.push(appointment);
9147
+ }
9148
+ });
9149
+ return Array.from(clinicMap.entries()).map(([clinicId, data]) => ({
9150
+ entityId: clinicId,
9151
+ entityName: data.name,
9152
+ entityType: "clinic",
9153
+ totalAppointments: data.all.length,
9154
+ noShowAppointments: data.noShow.length,
9155
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length)
9156
+ }));
9157
+ }
9158
+ /**
9159
+ * Group no-shows by practitioner
9160
+ */
9161
+ groupNoShowsByPractitioner(noShow, allAppointments) {
9162
+ const practitionerMap = /* @__PURE__ */ new Map();
9163
+ allAppointments.forEach((appointment) => {
9164
+ var _a;
9165
+ const practitionerId = appointment.practitionerId;
9166
+ const practitionerName = ((_a = appointment.practitionerInfo) == null ? void 0 : _a.name) || "Unknown";
9167
+ if (!practitionerMap.has(practitionerId)) {
9168
+ practitionerMap.set(practitionerId, { name: practitionerName, noShow: [], all: [] });
9169
+ }
9170
+ practitionerMap.get(practitionerId).all.push(appointment);
9171
+ });
9172
+ noShow.forEach((appointment) => {
9173
+ const practitionerId = appointment.practitionerId;
9174
+ if (practitionerMap.has(practitionerId)) {
9175
+ practitionerMap.get(practitionerId).noShow.push(appointment);
9176
+ }
9177
+ });
9178
+ return Array.from(practitionerMap.entries()).map(([practitionerId, data]) => ({
9179
+ entityId: practitionerId,
9180
+ entityName: data.name,
9181
+ entityType: "practitioner",
9182
+ totalAppointments: data.all.length,
9183
+ noShowAppointments: data.noShow.length,
9184
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length)
9185
+ }));
9186
+ }
9187
+ /**
9188
+ * Group no-shows by patient
9189
+ */
9190
+ groupNoShowsByPatient(noShow, allAppointments) {
9191
+ const patientMap = /* @__PURE__ */ new Map();
9192
+ allAppointments.forEach((appointment) => {
9193
+ var _a;
9194
+ const patientId = appointment.patientId;
9195
+ const patientName = ((_a = appointment.patientInfo) == null ? void 0 : _a.fullName) || "Unknown";
9196
+ if (!patientMap.has(patientId)) {
9197
+ patientMap.set(patientId, { name: patientName, noShow: [], all: [] });
9198
+ }
9199
+ patientMap.get(patientId).all.push(appointment);
9200
+ });
9201
+ noShow.forEach((appointment) => {
9202
+ const patientId = appointment.patientId;
9203
+ if (patientMap.has(patientId)) {
9204
+ patientMap.get(patientId).noShow.push(appointment);
9205
+ }
9206
+ });
9207
+ return Array.from(patientMap.entries()).map(([patientId, data]) => ({
9208
+ entityId: patientId,
9209
+ entityName: data.name,
9210
+ entityType: "patient",
9211
+ totalAppointments: data.all.length,
9212
+ noShowAppointments: data.noShow.length,
9213
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length)
9214
+ }));
9215
+ }
9216
+ /**
9217
+ * Group no-shows by procedure
9218
+ */
9219
+ groupNoShowsByProcedure(noShow, allAppointments) {
9220
+ const procedureMap = /* @__PURE__ */ new Map();
9221
+ allAppointments.forEach((appointment) => {
9222
+ var _a, _b;
9223
+ const procedureId = appointment.procedureId;
9224
+ const procedureName = ((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown";
9225
+ if (!procedureMap.has(procedureId)) {
9226
+ procedureMap.set(procedureId, {
9227
+ name: procedureName,
9228
+ noShow: [],
9229
+ all: [],
9230
+ practitionerId: appointment.practitionerId,
9231
+ practitionerName: (_b = appointment.practitionerInfo) == null ? void 0 : _b.name
9232
+ });
9233
+ }
9234
+ procedureMap.get(procedureId).all.push(appointment);
9235
+ });
9236
+ noShow.forEach((appointment) => {
9237
+ const procedureId = appointment.procedureId;
9238
+ if (procedureMap.has(procedureId)) {
9239
+ procedureMap.get(procedureId).noShow.push(appointment);
9240
+ }
9241
+ });
9242
+ return Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
9243
+ entityId: procedureId,
9244
+ entityName: data.name,
9245
+ entityType: "procedure",
9246
+ totalAppointments: data.all.length,
9247
+ noShowAppointments: data.noShow.length,
9248
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length),
9249
+ ...data.practitionerId && { practitionerId: data.practitionerId },
9250
+ ...data.practitionerName && { practitionerName: data.practitionerName }
9251
+ }));
9252
+ }
9253
+ /**
9254
+ * Group no-shows by technology
9255
+ * Aggregates all procedures using the same technology across all doctors
9256
+ */
9257
+ groupNoShowsByTechnology(noShow, allAppointments) {
9258
+ const technologyMap = /* @__PURE__ */ new Map();
9259
+ allAppointments.forEach((appointment) => {
9260
+ var _a, _b, _c;
9261
+ const technologyId = ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
9262
+ const technologyName = ((_b = appointment.procedureExtendedInfo) == null ? void 0 : _b.procedureTechnologyName) || ((_c = appointment.procedureInfo) == null ? void 0 : _c.technologyName) || "Unknown";
9263
+ if (!technologyMap.has(technologyId)) {
9264
+ technologyMap.set(technologyId, { name: technologyName, noShow: [], all: [] });
9265
+ }
9266
+ technologyMap.get(technologyId).all.push(appointment);
9267
+ });
9268
+ noShow.forEach((appointment) => {
9269
+ var _a;
9270
+ const technologyId = ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
9271
+ if (technologyMap.has(technologyId)) {
9272
+ technologyMap.get(technologyId).noShow.push(appointment);
9273
+ }
9274
+ });
9275
+ return Array.from(technologyMap.entries()).map(([technologyId, data]) => ({
9276
+ entityId: technologyId,
9277
+ entityName: data.name,
9278
+ entityType: "technology",
9279
+ totalAppointments: data.all.length,
9280
+ noShowAppointments: data.noShow.length,
9281
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length)
9282
+ }));
9283
+ }
9284
+ // ==========================================
9285
+ // Financial Analytics
9286
+ // ==========================================
9287
+ /**
9288
+ * Get revenue metrics grouped by clinic, practitioner, procedure, patient, or technology
9289
+ *
9290
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient' | 'technology'
9291
+ * @param dateRange - Optional date range filter
9292
+ * @param filters - Optional additional filters
9293
+ * @returns Grouped revenue metrics
9294
+ */
9295
+ async getRevenueMetricsByEntity(groupBy, dateRange, filters) {
9296
+ const appointments = await this.fetchAppointments(filters, dateRange);
9297
+ return calculateGroupedRevenueMetrics(appointments, groupBy);
9298
+ }
9299
+ /**
9300
+ * Get revenue metrics
9301
+ * First checks for stored analytics, then calculates if not available or stale
9302
+ *
9303
+ * IMPORTANT: Financial calculations only consider COMPLETED appointments.
9304
+ * Confirmed, pending, canceled, and no-show appointments are NOT included in revenue calculations.
9305
+ * Only procedures that have been completed generate revenue.
9306
+ *
9307
+ * @param filters - Optional filters
9308
+ * @param dateRange - Optional date range filter
9309
+ * @param options - Options for reading stored analytics
9310
+ * @returns Revenue metrics
9311
+ */
9312
+ async getRevenueMetrics(filters, dateRange, options) {
9313
+ if ((filters == null ? void 0 : filters.clinicBranchId) && dateRange && (options == null ? void 0 : options.useCache) !== false) {
9314
+ const period = this.determinePeriodFromDateRange(dateRange);
9315
+ const stored = await readStoredRevenueMetrics(
9316
+ this.db,
9317
+ filters.clinicBranchId,
9318
+ { ...options, period }
9319
+ );
9320
+ if (stored) {
9321
+ const { metadata, ...metrics } = stored;
9322
+ return metrics;
9323
+ }
9324
+ }
9325
+ const appointments = await this.fetchAppointments(filters, dateRange);
9326
+ const completed = getCompletedAppointments(appointments);
9327
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
9328
+ const revenueByStatus = {};
9329
+ const { totalRevenue: completedRevenue } = calculateTotalRevenue(completed);
9330
+ revenueByStatus["completed" /* COMPLETED */] = completedRevenue;
9331
+ const revenueByPaymentStatus = {};
9332
+ Object.values(PaymentStatus).forEach((paymentStatus) => {
9333
+ const paymentAppointments = completed.filter((a) => a.paymentStatus === paymentStatus);
9334
+ const { totalRevenue: paymentRevenue } = calculateTotalRevenue(paymentAppointments);
9335
+ revenueByPaymentStatus[paymentStatus] = paymentRevenue;
9336
+ });
9337
+ const unpaid = completed.filter((a) => a.paymentStatus === "unpaid" /* UNPAID */);
9338
+ const refunded = completed.filter((a) => a.paymentStatus === "refunded" /* REFUNDED */);
9339
+ const { totalRevenue: unpaidRevenue } = calculateTotalRevenue(unpaid);
9340
+ const { totalRevenue: refundedRevenue } = calculateTotalRevenue(refunded);
9341
+ let totalTax = 0;
9342
+ let totalSubtotal = 0;
9343
+ completed.forEach((appointment) => {
9344
+ const costData = calculateAppointmentCost(appointment);
9345
+ if (costData.source === "finalbilling") {
9346
+ totalTax += costData.tax || 0;
9347
+ totalSubtotal += costData.subtotal || 0;
9348
+ } else {
9349
+ totalSubtotal += costData.cost;
9350
+ }
9351
+ });
9352
+ return {
9353
+ totalRevenue,
9354
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
9355
+ totalAppointments: appointments.length,
9356
+ completedAppointments: completed.length,
9357
+ currency,
9358
+ revenueByStatus,
9359
+ revenueByPaymentStatus,
9360
+ unpaidRevenue,
9361
+ refundedRevenue,
9362
+ totalTax,
9363
+ totalSubtotal
9364
+ };
9365
+ }
9366
+ // ==========================================
9367
+ // Product Usage Analytics
9368
+ // ==========================================
9369
+ /**
9370
+ * Get product usage metrics grouped by clinic, practitioner, procedure, or patient
9371
+ *
9372
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient'
9373
+ * @param dateRange - Optional date range filter
9374
+ * @param filters - Optional additional filters
9375
+ * @returns Grouped product usage metrics
9376
+ */
9377
+ async getProductUsageMetricsByEntity(groupBy, dateRange, filters) {
9378
+ const appointments = await this.fetchAppointments(filters, dateRange);
9379
+ return calculateGroupedProductUsageMetrics(appointments, groupBy);
9380
+ }
9381
+ /**
9382
+ * Get product usage metrics
9383
+ *
9384
+ * IMPORTANT: Only COMPLETED appointments are included in product usage calculations.
9385
+ * Products are only considered "used" when the procedure has been completed.
9386
+ * Confirmed, pending, canceled, and no-show appointments are excluded from product metrics.
9387
+ *
9388
+ * @param productId - Optional product ID (if not provided, returns all products)
9389
+ * @param dateRange - Optional date range filter
9390
+ * @param filters - Optional filters (e.g., clinicBranchId)
9391
+ * @returns Product usage metrics
9392
+ */
9393
+ async getProductUsageMetrics(productId, dateRange, filters) {
9394
+ const appointments = await this.fetchAppointments(filters, dateRange);
9395
+ const completed = getCompletedAppointments(appointments);
9396
+ const productMap = /* @__PURE__ */ new Map();
9397
+ completed.forEach((appointment) => {
9398
+ const products = extractProductUsage(appointment);
9399
+ const productsInThisAppointment = /* @__PURE__ */ new Set();
9400
+ products.forEach((product) => {
9401
+ if (productId && product.productId !== productId) {
9402
+ return;
9403
+ }
9404
+ if (!productMap.has(product.productId)) {
9405
+ productMap.set(product.productId, {
9406
+ name: product.productName,
9407
+ brandId: product.brandId,
9408
+ brandName: product.brandName,
9409
+ quantity: 0,
9410
+ revenue: 0,
9411
+ usageCount: 0,
9412
+ appointmentIds: /* @__PURE__ */ new Set(),
9413
+ procedureMap: /* @__PURE__ */ new Map()
9414
+ });
9415
+ }
9416
+ const productData = productMap.get(product.productId);
9417
+ productData.quantity += product.quantity;
9418
+ productData.revenue += product.subtotal;
9419
+ productsInThisAppointment.add(product.productId);
9420
+ });
9421
+ productsInThisAppointment.forEach((productId2) => {
9422
+ var _a;
9423
+ const productData = productMap.get(productId2);
9424
+ if (!productData.appointmentIds.has(appointment.id)) {
9425
+ productData.appointmentIds.add(appointment.id);
9426
+ productData.usageCount++;
9427
+ const procId = appointment.procedureId;
9428
+ const procName = ((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown";
9429
+ if (productData.procedureMap.has(procId)) {
9430
+ const procData = productData.procedureMap.get(procId);
9431
+ procData.count++;
9432
+ const appointmentProducts = products.filter((p) => p.productId === productId2);
9433
+ procData.quantity += appointmentProducts.reduce((sum, p) => sum + p.quantity, 0);
9434
+ } else {
9435
+ const appointmentProducts = products.filter((p) => p.productId === productId2);
9436
+ const totalQuantity = appointmentProducts.reduce((sum, p) => sum + p.quantity, 0);
9437
+ productData.procedureMap.set(procId, {
9438
+ name: procName,
9439
+ count: 1,
9440
+ quantity: totalQuantity
9441
+ });
9442
+ }
9443
+ }
9444
+ });
9445
+ });
9446
+ const results = Array.from(productMap.entries()).map(([productId2, data]) => ({
9447
+ productId: productId2,
9448
+ productName: data.name,
9449
+ brandId: data.brandId,
9450
+ brandName: data.brandName,
9451
+ totalQuantity: data.quantity,
9452
+ totalRevenue: data.revenue,
9453
+ averagePrice: data.usageCount > 0 ? data.revenue / data.quantity : 0,
9454
+ currency: "CHF",
9455
+ // Could be extracted from products
9456
+ usageCount: data.usageCount,
9457
+ averageQuantityPerAppointment: data.usageCount > 0 ? data.quantity / data.usageCount : 0,
9458
+ usageByProcedure: Array.from(data.procedureMap.entries()).map(([procId, procData]) => ({
9459
+ procedureId: procId,
9460
+ procedureName: procData.name,
9461
+ count: procData.count,
9462
+ totalQuantity: procData.quantity
9463
+ }))
9464
+ }));
9465
+ return productId ? results[0] : results;
9466
+ }
9467
+ // ==========================================
9468
+ // Patient Analytics
9469
+ // ==========================================
9470
+ /**
9471
+ * Get patient behavior metrics grouped by clinic, practitioner, procedure, or technology
9472
+ * Shows patient no-show and cancellation patterns per entity
9473
+ *
9474
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'technology'
9475
+ * @param dateRange - Optional date range filter
9476
+ * @param filters - Optional additional filters
9477
+ * @returns Grouped patient behavior metrics
9478
+ */
9479
+ async getPatientBehaviorMetricsByEntity(groupBy, dateRange, filters) {
9480
+ const appointments = await this.fetchAppointments(filters, dateRange);
9481
+ return calculateGroupedPatientBehaviorMetrics(appointments, groupBy);
9482
+ }
9483
+ /**
9484
+ * Get patient analytics
9485
+ *
9486
+ * @param patientId - Optional patient ID (if not provided, returns aggregate)
9487
+ * @param dateRange - Optional date range filter
9488
+ * @returns Patient analytics
9489
+ */
9490
+ async getPatientAnalytics(patientId, dateRange) {
9491
+ const appointments = await this.fetchAppointments(patientId ? { patientId } : void 0, dateRange);
9492
+ if (patientId) {
9493
+ return this.calculatePatientAnalytics(appointments, patientId);
9494
+ }
9495
+ const patientMap = /* @__PURE__ */ new Map();
9496
+ appointments.forEach((appointment) => {
9497
+ const patId = appointment.patientId;
9498
+ if (!patientMap.has(patId)) {
9499
+ patientMap.set(patId, []);
9500
+ }
9501
+ patientMap.get(patId).push(appointment);
9502
+ });
9503
+ return Array.from(patientMap.entries()).map(
9504
+ ([patId, patAppointments]) => this.calculatePatientAnalytics(patAppointments, patId)
9505
+ );
9506
+ }
9507
+ /**
9508
+ * Calculate analytics for a specific patient
9509
+ */
9510
+ calculatePatientAnalytics(appointments, patientId) {
9511
+ var _a;
9512
+ const completed = getCompletedAppointments(appointments);
9513
+ const canceled = getCanceledAppointments(appointments);
9514
+ const noShow = getNoShowAppointments(appointments);
9515
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
9516
+ const appointmentDates = appointments.map((a) => a.appointmentStartTime.toDate()).sort((a, b) => a.getTime() - b.getTime());
9517
+ const firstAppointmentDate = appointmentDates.length > 0 ? appointmentDates[0] : null;
9518
+ const lastAppointmentDate = appointmentDates.length > 0 ? appointmentDates[appointmentDates.length - 1] : null;
9519
+ let averageDaysBetween = null;
9520
+ if (appointmentDates.length > 1) {
9521
+ const intervals = [];
9522
+ for (let i = 1; i < appointmentDates.length; i++) {
9523
+ const diffMs = appointmentDates[i].getTime() - appointmentDates[i - 1].getTime();
9524
+ intervals.push(diffMs / (1e3 * 60 * 60 * 24));
9525
+ }
9526
+ averageDaysBetween = intervals.reduce((a, b) => a + b, 0) / intervals.length;
9527
+ }
9528
+ const uniquePractitioners = new Set(appointments.map((a) => a.practitionerId));
9529
+ const uniqueClinics = new Set(appointments.map((a) => a.clinicBranchId));
9530
+ const procedureMap = /* @__PURE__ */ new Map();
9531
+ completed.forEach((appointment) => {
9532
+ var _a2, _b;
9533
+ const procId = appointment.procedureId;
9534
+ const procName = ((_a2 = appointment.procedureInfo) == null ? void 0 : _a2.name) || "Unknown";
9535
+ procedureMap.set(procId, {
9536
+ name: procName,
9537
+ count: (((_b = procedureMap.get(procId)) == null ? void 0 : _b.count) || 0) + 1
9538
+ });
9539
+ });
9540
+ const favoriteProcedures = Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
9541
+ procedureId,
9542
+ procedureName: data.name,
9543
+ count: data.count
9544
+ })).sort((a, b) => b.count - a.count).slice(0, 5);
9545
+ const patientName = appointments.length > 0 ? ((_a = appointments[0].patientInfo) == null ? void 0 : _a.fullName) || "Unknown" : "Unknown";
9546
+ return {
9547
+ patientId,
9548
+ patientName,
9549
+ totalAppointments: appointments.length,
9550
+ completedAppointments: completed.length,
9551
+ canceledAppointments: canceled.length,
9552
+ noShowAppointments: noShow.length,
9553
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
9554
+ noShowRate: calculatePercentage(noShow.length, appointments.length),
9555
+ totalRevenue,
9556
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
9557
+ currency,
9558
+ lifetimeValue: totalRevenue,
9559
+ firstAppointmentDate,
9560
+ lastAppointmentDate,
9561
+ averageDaysBetweenAppointments: averageDaysBetween ? Math.round(averageDaysBetween) : null,
9562
+ uniquePractitioners: uniquePractitioners.size,
9563
+ uniqueClinics: uniqueClinics.size,
9564
+ favoriteProcedures
9565
+ };
9566
+ }
9567
+ // ==========================================
9568
+ // Dashboard Analytics
9569
+ // ==========================================
9570
+ /**
9571
+ * Determines analytics period from date range
9572
+ */
9573
+ determinePeriodFromDateRange(dateRange) {
9574
+ const diffMs = dateRange.end.getTime() - dateRange.start.getTime();
9575
+ const diffDays = diffMs / (1e3 * 60 * 60 * 24);
9576
+ if (diffDays <= 1) return "daily";
9577
+ if (diffDays <= 7) return "weekly";
9578
+ if (diffDays <= 31) return "monthly";
9579
+ if (diffDays <= 365) return "yearly";
9580
+ return "all_time";
9581
+ }
9582
+ /**
9583
+ * Get comprehensive dashboard data
9584
+ * First checks for stored analytics, then calculates if not available or stale
9585
+ *
9586
+ * @param filters - Optional filters
9587
+ * @param dateRange - Optional date range filter
9588
+ * @param options - Options for reading stored analytics
9589
+ * @returns Complete dashboard analytics
9590
+ */
9591
+ async getDashboardData(filters, dateRange, options) {
9592
+ if ((filters == null ? void 0 : filters.clinicBranchId) && dateRange && (options == null ? void 0 : options.useCache) !== false) {
9593
+ const period = this.determinePeriodFromDateRange(dateRange);
9594
+ const stored = await readStoredDashboardAnalytics(
9595
+ this.db,
9596
+ filters.clinicBranchId,
9597
+ { ...options, period }
9598
+ );
9599
+ if (stored) {
9600
+ const { metadata, ...analytics } = stored;
9601
+ return analytics;
9602
+ }
9603
+ }
9604
+ const appointments = await this.fetchAppointments(filters, dateRange);
9605
+ const completed = getCompletedAppointments(appointments);
9606
+ const canceled = getCanceledAppointments(appointments);
9607
+ const noShow = getNoShowAppointments(appointments);
9608
+ const pending = appointments.filter((a) => a.status === "pending" /* PENDING */);
9609
+ const confirmed = appointments.filter((a) => a.status === "confirmed" /* CONFIRMED */);
9610
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
9611
+ const uniquePatients = new Set(appointments.map((a) => a.patientId));
9612
+ const uniquePractitioners = new Set(appointments.map((a) => a.practitionerId));
9613
+ const uniqueProcedures = new Set(appointments.map((a) => a.procedureId));
9614
+ const practitionerMetrics = await Promise.all(
9615
+ Array.from(uniquePractitioners).slice(0, 5).map((practitionerId) => this.getPractitionerAnalytics(practitionerId, dateRange))
9616
+ );
9617
+ const procedureMetricsResults = await Promise.all(
9618
+ Array.from(uniqueProcedures).slice(0, 5).map((procedureId) => this.getProcedureAnalytics(procedureId, dateRange))
9619
+ );
9620
+ const procedureMetrics = procedureMetricsResults.filter(
9621
+ (result) => !Array.isArray(result)
9622
+ );
9623
+ const cancellationMetrics = await this.getCancellationMetrics("clinic", dateRange);
9624
+ const noShowMetrics = await this.getNoShowMetrics("clinic", dateRange);
9625
+ const timeEfficiency = await this.getTimeEfficiencyMetrics(filters, dateRange);
9626
+ const productMetrics = await this.getProductUsageMetrics(void 0, dateRange);
9627
+ const topProducts = Array.isArray(productMetrics) ? productMetrics.sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, 5) : [];
9628
+ const recentActivity = appointments.sort((a, b) => b.appointmentStartTime.toMillis() - a.appointmentStartTime.toMillis()).slice(0, 10).map((appointment) => {
9629
+ var _a, _b, _c, _d, _e;
9630
+ let type = "appointment";
9631
+ let description = "";
9632
+ if (appointment.status === "completed" /* COMPLETED */) {
9633
+ type = "completion";
9634
+ description = `Appointment completed: ${((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown procedure"}`;
9635
+ } else if (appointment.status === "canceled_patient" /* CANCELED_PATIENT */ || appointment.status === "canceled_clinic" /* CANCELED_CLINIC */ || appointment.status === "canceled_patient_rescheduled" /* CANCELED_PATIENT_RESCHEDULED */) {
9636
+ type = "cancellation";
9637
+ description = `Appointment canceled: ${((_b = appointment.procedureInfo) == null ? void 0 : _b.name) || "Unknown procedure"}`;
9638
+ } else if (appointment.status === "no_show" /* NO_SHOW */) {
9639
+ type = "no_show";
9640
+ description = `No-show: ${((_c = appointment.procedureInfo) == null ? void 0 : _c.name) || "Unknown procedure"}`;
9641
+ } else {
9642
+ description = `Appointment ${appointment.status}: ${((_d = appointment.procedureInfo) == null ? void 0 : _d.name) || "Unknown procedure"}`;
9643
+ }
9644
+ return {
9645
+ type,
9646
+ date: appointment.appointmentStartTime.toDate(),
9647
+ description,
9648
+ entityId: appointment.practitionerId,
9649
+ entityName: ((_e = appointment.practitionerInfo) == null ? void 0 : _e.name) || "Unknown"
9650
+ };
9651
+ });
9652
+ return {
9653
+ overview: {
9654
+ totalAppointments: appointments.length,
9655
+ completedAppointments: completed.length,
9656
+ canceledAppointments: canceled.length,
9657
+ noShowAppointments: noShow.length,
9658
+ pendingAppointments: pending.length,
9659
+ confirmedAppointments: confirmed.length,
9660
+ totalRevenue,
9661
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
9662
+ currency,
9663
+ uniquePatients: uniquePatients.size,
9664
+ uniquePractitioners: uniquePractitioners.size,
9665
+ uniqueProcedures: uniqueProcedures.size,
9666
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
9667
+ noShowRate: calculatePercentage(noShow.length, appointments.length)
9668
+ },
9669
+ practitionerMetrics: Array.isArray(practitionerMetrics) ? practitionerMetrics : [],
9670
+ procedureMetrics: Array.isArray(procedureMetrics) ? procedureMetrics : [],
9671
+ cancellationMetrics: Array.isArray(cancellationMetrics) ? cancellationMetrics[0] : cancellationMetrics,
9672
+ noShowMetrics: Array.isArray(noShowMetrics) ? noShowMetrics[0] : noShowMetrics,
9673
+ revenueTrends: [],
9674
+ // TODO: Implement revenue trends
9675
+ timeEfficiency,
9676
+ topProducts,
9677
+ recentActivity
9678
+ };
9679
+ }
9680
+ /**
9681
+ * Calculate revenue trends over time
9682
+ * Groups appointments by week/month/quarter/year and calculates revenue metrics
9683
+ *
9684
+ * @param dateRange - Date range for trend analysis (must align with period boundaries)
9685
+ * @param period - Period type (week, month, quarter, year)
9686
+ * @param filters - Optional filters for clinic, practitioner, procedure, patient
9687
+ * @param groupBy - Optional entity type to group trends by (clinic, practitioner, procedure, technology, patient)
9688
+ * @returns Array of revenue trends with percentage changes
9689
+ */
9690
+ async getRevenueTrends(dateRange, period, filters, groupBy) {
9691
+ const appointments = await this.fetchAppointments(filters);
9692
+ const filtered = filterByDateRange(appointments, dateRange);
9693
+ if (filtered.length === 0) {
9694
+ return [];
9695
+ }
9696
+ if (groupBy) {
9697
+ return this.getGroupedRevenueTrends(filtered, dateRange, period, groupBy);
9698
+ }
9699
+ const periodMap = groupAppointmentsByPeriod(filtered, period);
9700
+ const periods = generatePeriods(dateRange.start, dateRange.end, period);
9701
+ const trends = [];
9702
+ let previousRevenue = 0;
9703
+ let previousAppointmentCount = 0;
9704
+ periods.forEach((periodInfo) => {
9705
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
9706
+ const completed = getCompletedAppointments(periodAppointments);
9707
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
9708
+ const appointmentCount = completed.length;
9709
+ const averageRevenue = appointmentCount > 0 ? totalRevenue / appointmentCount : 0;
9710
+ const trend = {
9711
+ period: periodInfo.period,
9712
+ startDate: periodInfo.startDate,
9713
+ endDate: periodInfo.endDate,
9714
+ revenue: totalRevenue,
9715
+ appointmentCount,
9716
+ averageRevenue,
9717
+ currency
9718
+ };
9719
+ if (previousRevenue > 0 || previousAppointmentCount > 0) {
9720
+ const revenueChange = getTrendChange(totalRevenue, previousRevenue);
9721
+ trend.previousPeriod = {
9722
+ revenue: previousRevenue,
9723
+ appointmentCount: previousAppointmentCount,
9724
+ percentageChange: revenueChange.percentageChange,
9725
+ direction: revenueChange.direction
9726
+ };
9727
+ }
9728
+ trends.push(trend);
9729
+ previousRevenue = totalRevenue;
9730
+ previousAppointmentCount = appointmentCount;
9731
+ });
9732
+ return trends;
9733
+ }
9734
+ /**
9735
+ * Calculate revenue trends grouped by entity
9736
+ */
9737
+ async getGroupedRevenueTrends(appointments, dateRange, period, groupBy) {
9738
+ const periodMap = groupAppointmentsByPeriod(appointments, period);
9739
+ const periods = generatePeriods(dateRange.start, dateRange.end, period);
9740
+ const trends = [];
9741
+ periods.forEach((periodInfo) => {
9742
+ var _a;
9743
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
9744
+ if (periodAppointments.length === 0) return;
9745
+ const groupedMetrics = calculateGroupedRevenueMetrics(periodAppointments, groupBy);
9746
+ const totalRevenue = groupedMetrics.reduce((sum, m) => sum + m.totalRevenue, 0);
9747
+ const totalAppointments = groupedMetrics.reduce((sum, m) => sum + m.totalAppointments, 0);
9748
+ const currency = ((_a = groupedMetrics[0]) == null ? void 0 : _a.currency) || "CHF";
9749
+ const averageRevenue = totalAppointments > 0 ? totalRevenue / totalAppointments : 0;
9750
+ trends.push({
9751
+ period: periodInfo.period,
9752
+ startDate: periodInfo.startDate,
9753
+ endDate: periodInfo.endDate,
9754
+ revenue: totalRevenue,
9755
+ appointmentCount: totalAppointments,
9756
+ averageRevenue,
9757
+ currency
9758
+ });
9759
+ });
9760
+ for (let i = 1; i < trends.length; i++) {
9761
+ const current = trends[i];
9762
+ const previous = trends[i - 1];
9763
+ const revenueChange = getTrendChange(current.revenue, previous.revenue);
9764
+ current.previousPeriod = {
9765
+ revenue: previous.revenue,
9766
+ appointmentCount: previous.appointmentCount,
9767
+ percentageChange: revenueChange.percentageChange,
9768
+ direction: revenueChange.direction
9769
+ };
9770
+ }
9771
+ return trends;
9772
+ }
9773
+ /**
9774
+ * Calculate duration/efficiency trends over time
9775
+ *
9776
+ * @param dateRange - Date range for trend analysis
9777
+ * @param period - Period type (week, month, quarter, year)
9778
+ * @param filters - Optional filters
9779
+ * @param groupBy - Optional entity type to group trends by
9780
+ * @returns Array of duration trends with percentage changes
9781
+ */
9782
+ async getDurationTrends(dateRange, period, filters, groupBy) {
9783
+ const appointments = await this.fetchAppointments(filters);
9784
+ const filtered = filterByDateRange(appointments, dateRange);
9785
+ if (filtered.length === 0) {
9786
+ return [];
9787
+ }
9788
+ const periodMap = groupAppointmentsByPeriod(filtered, period);
9789
+ const periods = generatePeriods(dateRange.start, dateRange.end, period);
9790
+ const trends = [];
9791
+ let previousEfficiency = 0;
9792
+ let previousBookedDuration = 0;
9793
+ let previousActualDuration = 0;
9794
+ periods.forEach((periodInfo) => {
9795
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
9796
+ const completed = getCompletedAppointments(periodAppointments);
9797
+ if (groupBy) {
9798
+ const groupedMetrics = calculateGroupedTimeEfficiencyMetrics(completed, groupBy);
9799
+ if (groupedMetrics.length === 0) return;
9800
+ const totalAppointments = groupedMetrics.reduce((sum, m) => sum + m.totalAppointments, 0);
9801
+ const weightedBooked = groupedMetrics.reduce(
9802
+ (sum, m) => sum + m.averageBookedDuration * m.totalAppointments,
9803
+ 0
9804
+ );
9805
+ const weightedActual = groupedMetrics.reduce(
9806
+ (sum, m) => sum + m.averageActualDuration * m.totalAppointments,
9807
+ 0
9808
+ );
9809
+ const weightedEfficiency = groupedMetrics.reduce(
9810
+ (sum, m) => sum + m.averageEfficiency * m.totalAppointments,
9811
+ 0
9812
+ );
9813
+ const averageBookedDuration = totalAppointments > 0 ? weightedBooked / totalAppointments : 0;
9814
+ const averageActualDuration = totalAppointments > 0 ? weightedActual / totalAppointments : 0;
9815
+ const averageEfficiency = totalAppointments > 0 ? weightedEfficiency / totalAppointments : 0;
9816
+ const trend = {
9817
+ period: periodInfo.period,
9818
+ startDate: periodInfo.startDate,
9819
+ endDate: periodInfo.endDate,
9820
+ averageBookedDuration,
9821
+ averageActualDuration,
9822
+ averageEfficiency,
9823
+ appointmentCount: totalAppointments
9824
+ };
9825
+ if (previousEfficiency > 0) {
9826
+ const efficiencyChange = getTrendChange(averageEfficiency, previousEfficiency);
9827
+ trend.previousPeriod = {
9828
+ averageBookedDuration: previousBookedDuration,
9829
+ averageActualDuration: previousActualDuration,
9830
+ averageEfficiency: previousEfficiency,
9831
+ efficiencyPercentageChange: efficiencyChange.percentageChange,
9832
+ direction: efficiencyChange.direction
9833
+ };
9834
+ }
9835
+ trends.push(trend);
9836
+ previousEfficiency = averageEfficiency;
9837
+ previousBookedDuration = averageBookedDuration;
9838
+ previousActualDuration = averageActualDuration;
9839
+ } else {
9840
+ const timeMetrics = calculateAverageTimeMetrics(completed);
9841
+ const trend = {
9842
+ period: periodInfo.period,
9843
+ startDate: periodInfo.startDate,
9844
+ endDate: periodInfo.endDate,
9845
+ averageBookedDuration: timeMetrics.averageBookedDuration,
9846
+ averageActualDuration: timeMetrics.averageActualDuration,
9847
+ averageEfficiency: timeMetrics.averageEfficiency,
9848
+ appointmentCount: timeMetrics.appointmentsWithActualTime
9849
+ };
9850
+ if (previousEfficiency > 0) {
9851
+ const efficiencyChange = getTrendChange(timeMetrics.averageEfficiency, previousEfficiency);
9852
+ trend.previousPeriod = {
9853
+ averageBookedDuration: previousBookedDuration,
9854
+ averageActualDuration: previousActualDuration,
9855
+ averageEfficiency: previousEfficiency,
9856
+ efficiencyPercentageChange: efficiencyChange.percentageChange,
9857
+ direction: efficiencyChange.direction
9858
+ };
9859
+ }
9860
+ trends.push(trend);
9861
+ previousEfficiency = timeMetrics.averageEfficiency;
9862
+ previousBookedDuration = timeMetrics.averageBookedDuration;
9863
+ previousActualDuration = timeMetrics.averageActualDuration;
9864
+ }
9865
+ });
9866
+ return trends;
9867
+ }
9868
+ /**
9869
+ * Calculate appointment count trends over time
9870
+ *
9871
+ * @param dateRange - Date range for trend analysis
9872
+ * @param period - Period type (week, month, quarter, year)
9873
+ * @param filters - Optional filters
9874
+ * @param groupBy - Optional entity type to group trends by
9875
+ * @returns Array of appointment trends with percentage changes
9876
+ */
9877
+ async getAppointmentTrends(dateRange, period, filters, groupBy) {
9878
+ const appointments = await this.fetchAppointments(filters);
9879
+ const filtered = filterByDateRange(appointments, dateRange);
9880
+ if (filtered.length === 0) {
9881
+ return [];
9882
+ }
9883
+ const periodMap = groupAppointmentsByPeriod(filtered, period);
9884
+ const periods = generatePeriods(dateRange.start, dateRange.end, period);
9885
+ const trends = [];
9886
+ let previousTotal = 0;
9887
+ let previousCompleted = 0;
9888
+ periods.forEach((periodInfo) => {
9889
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
9890
+ const completed = getCompletedAppointments(periodAppointments);
9891
+ const canceled = getCanceledAppointments(periodAppointments);
9892
+ const noShow = getNoShowAppointments(periodAppointments);
9893
+ const pending = periodAppointments.filter((a) => a.status === "pending" /* PENDING */);
9894
+ const confirmed = periodAppointments.filter((a) => a.status === "confirmed" /* CONFIRMED */);
9895
+ const trend = {
9896
+ period: periodInfo.period,
9897
+ startDate: periodInfo.startDate,
9898
+ endDate: periodInfo.endDate,
9899
+ totalAppointments: periodAppointments.length,
9900
+ completedAppointments: completed.length,
9901
+ canceledAppointments: canceled.length,
9902
+ noShowAppointments: noShow.length,
9903
+ pendingAppointments: pending.length,
9904
+ confirmedAppointments: confirmed.length
9905
+ };
9906
+ if (previousTotal > 0) {
9907
+ const totalChange = getTrendChange(periodAppointments.length, previousTotal);
9908
+ trend.previousPeriod = {
9909
+ totalAppointments: previousTotal,
9910
+ completedAppointments: previousCompleted,
9911
+ percentageChange: totalChange.percentageChange,
9912
+ direction: totalChange.direction
9913
+ };
9914
+ }
9915
+ trends.push(trend);
9916
+ previousTotal = periodAppointments.length;
9917
+ previousCompleted = completed.length;
9918
+ });
9919
+ return trends;
9920
+ }
9921
+ /**
9922
+ * Calculate cancellation and no-show rate trends over time
9923
+ *
9924
+ * @param dateRange - Date range for trend analysis
9925
+ * @param period - Period type (week, month, quarter, year)
9926
+ * @param filters - Optional filters
9927
+ * @param groupBy - Optional entity type to group trends by
9928
+ * @returns Array of cancellation rate trends with percentage changes
9929
+ */
9930
+ async getCancellationRateTrends(dateRange, period, filters, groupBy) {
9931
+ const appointments = await this.fetchAppointments(filters);
9932
+ const filtered = filterByDateRange(appointments, dateRange);
9933
+ if (filtered.length === 0) {
9934
+ return [];
9935
+ }
9936
+ const periodMap = groupAppointmentsByPeriod(filtered, period);
9937
+ const periods = generatePeriods(dateRange.start, dateRange.end, period);
9938
+ const trends = [];
9939
+ let previousCancellationRate = 0;
9940
+ let previousNoShowRate = 0;
9941
+ periods.forEach((periodInfo) => {
9942
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
9943
+ const canceled = getCanceledAppointments(periodAppointments);
9944
+ const noShow = getNoShowAppointments(periodAppointments);
9945
+ const cancellationRate = calculatePercentage(canceled.length, periodAppointments.length);
9946
+ const noShowRate = calculatePercentage(noShow.length, periodAppointments.length);
9947
+ const trend = {
9948
+ period: periodInfo.period,
9949
+ startDate: periodInfo.startDate,
9950
+ endDate: periodInfo.endDate,
9951
+ cancellationRate,
9952
+ noShowRate,
9953
+ totalAppointments: periodAppointments.length,
9954
+ canceledAppointments: canceled.length,
9955
+ noShowAppointments: noShow.length
9956
+ };
9957
+ if (previousCancellationRate > 0 || previousNoShowRate > 0) {
9958
+ const cancellationChange = getTrendChange(cancellationRate, previousCancellationRate);
9959
+ const noShowChange = getTrendChange(noShowRate, previousNoShowRate);
9960
+ trend.previousPeriod = {
9961
+ cancellationRate: previousCancellationRate,
9962
+ noShowRate: previousNoShowRate,
9963
+ cancellationRateChange: cancellationChange.percentageChange,
9964
+ noShowRateChange: noShowChange.percentageChange,
9965
+ direction: cancellationChange.direction
9966
+ // Use cancellation direction as primary
9967
+ };
9968
+ }
9969
+ trends.push(trend);
9970
+ previousCancellationRate = cancellationRate;
9971
+ previousNoShowRate = noShowRate;
9972
+ });
9973
+ return trends;
9974
+ }
9975
+ // ==========================================
9976
+ // Review Analytics Methods
9977
+ // ==========================================
9978
+ /**
9979
+ * Get review metrics for a specific entity (practitioner, procedure, etc.)
9980
+ */
9981
+ async getReviewMetricsByEntity(entityType, entityId, dateRange, filters) {
9982
+ return this.reviewAnalyticsService.getReviewMetricsByEntity(entityType, entityId, dateRange, filters);
9983
+ }
9984
+ /**
9985
+ * Get review metrics for multiple entities (grouped)
9986
+ */
9987
+ async getReviewMetricsByEntities(entityType, dateRange, filters) {
9988
+ return this.reviewAnalyticsService.getReviewMetricsByEntities(entityType, dateRange, filters);
9989
+ }
9990
+ /**
9991
+ * Get overall review averages for comparison
9992
+ */
9993
+ async getOverallReviewAverages(dateRange, filters) {
9994
+ return this.reviewAnalyticsService.getOverallReviewAverages(dateRange, filters);
9995
+ }
9996
+ /**
9997
+ * Get review details for a specific entity
9998
+ */
9999
+ async getReviewDetails(entityType, entityId, dateRange, filters) {
10000
+ return this.reviewAnalyticsService.getReviewDetails(entityType, entityId, dateRange, filters);
10001
+ }
10002
+ /**
10003
+ * Calculate review trends over time
10004
+ * Groups reviews by period and calculates rating and recommendation metrics
10005
+ *
10006
+ * @param dateRange - Date range for trend analysis
10007
+ * @param period - Period type (week, month, quarter, year)
10008
+ * @param filters - Optional filters for clinic, practitioner, procedure
10009
+ * @param entityType - Optional entity type to group trends by
10010
+ * @returns Array of review trends with percentage changes
10011
+ */
10012
+ async getReviewTrends(dateRange, period, filters, entityType) {
10013
+ return this.reviewAnalyticsService.getReviewTrends(dateRange, period, filters, entityType);
10014
+ }
10015
+ };
10016
+
10017
+ // src/admin/analytics/analytics.admin.service.ts
10018
+ var AnalyticsAdminService = class {
10019
+ /**
10020
+ * Creates a new AnalyticsAdminService instance
10021
+ *
10022
+ * @param firestore - Admin Firestore instance (optional, defaults to admin.firestore())
10023
+ */
10024
+ constructor(firestore19) {
10025
+ this.db = firestore19 || admin14.firestore();
10026
+ const mockApp = {
10027
+ name: "[DEFAULT]",
10028
+ options: {},
10029
+ automaticDataCollectionEnabled: false
10030
+ };
10031
+ const mockAuth = {};
10032
+ const appointmentService = this.createAppointmentServiceAdapter();
10033
+ this.analyticsService = new AnalyticsService(
10034
+ this.db,
10035
+ // Cast admin Firestore to client Firestore type
10036
+ mockAuth,
10037
+ mockApp,
10038
+ appointmentService
10039
+ );
10040
+ }
10041
+ /**
10042
+ * Creates an adapter for AppointmentService to work with admin SDK
10043
+ */
10044
+ createAppointmentServiceAdapter() {
10045
+ return {
10046
+ searchAppointments: async (params) => {
10047
+ let query3 = this.db.collection(APPOINTMENTS_COLLECTION);
10048
+ if (params.clinicBranchId) {
10049
+ query3 = query3.where("clinicBranchId", "==", params.clinicBranchId);
10050
+ }
10051
+ if (params.practitionerId) {
10052
+ query3 = query3.where("practitionerId", "==", params.practitionerId);
10053
+ }
10054
+ if (params.procedureId) {
10055
+ query3 = query3.where("procedureId", "==", params.procedureId);
10056
+ }
10057
+ if (params.patientId) {
10058
+ query3 = query3.where("patientId", "==", params.patientId);
10059
+ }
10060
+ if (params.startDate) {
10061
+ const startDate = params.startDate instanceof Date ? params.startDate : params.startDate.toDate();
10062
+ const startTimestamp = admin14.firestore.Timestamp.fromDate(startDate);
10063
+ query3 = query3.where("appointmentStartTime", ">=", startTimestamp);
10064
+ }
10065
+ if (params.endDate) {
10066
+ const endDate = params.endDate instanceof Date ? params.endDate : params.endDate.toDate();
10067
+ const endTimestamp = admin14.firestore.Timestamp.fromDate(endDate);
10068
+ query3 = query3.where("appointmentStartTime", "<=", endTimestamp);
10069
+ }
10070
+ const snapshot = await query3.get();
10071
+ const appointments = snapshot.docs.map((doc3) => ({
10072
+ id: doc3.id,
10073
+ ...doc3.data()
10074
+ }));
10075
+ return {
10076
+ appointments,
10077
+ total: appointments.length
10078
+ };
10079
+ }
10080
+ };
10081
+ }
10082
+ // Delegate all methods to the underlying AnalyticsService
10083
+ // We expose them here so they can be called with admin SDK context
10084
+ async getPractitionerAnalytics(practitionerId, dateRange, options) {
10085
+ return this.analyticsService.getPractitionerAnalytics(practitionerId, dateRange, options);
10086
+ }
10087
+ async getProcedureAnalytics(procedureId, dateRange, options) {
10088
+ return this.analyticsService.getProcedureAnalytics(procedureId, dateRange, options);
10089
+ }
10090
+ async getTimeEfficiencyMetrics(filters, dateRange, options) {
10091
+ return this.analyticsService.getTimeEfficiencyMetrics(filters, dateRange, options);
10092
+ }
10093
+ async getTimeEfficiencyMetricsByEntity(groupBy, dateRange, filters) {
10094
+ return this.analyticsService.getTimeEfficiencyMetricsByEntity(groupBy, dateRange, filters);
10095
+ }
10096
+ async getCancellationMetrics(groupBy, dateRange, options) {
10097
+ return this.analyticsService.getCancellationMetrics(groupBy, dateRange, options);
10098
+ }
10099
+ async getNoShowMetrics(groupBy, dateRange, options) {
10100
+ return this.analyticsService.getNoShowMetrics(groupBy, dateRange, options);
10101
+ }
10102
+ async getRevenueMetrics(filters, dateRange, options) {
10103
+ return this.analyticsService.getRevenueMetrics(filters, dateRange, options);
10104
+ }
10105
+ async getRevenueMetricsByEntity(groupBy, dateRange, filters) {
10106
+ return this.analyticsService.getRevenueMetricsByEntity(groupBy, dateRange, filters);
10107
+ }
10108
+ async getProductUsageMetrics(productId, dateRange) {
10109
+ return this.analyticsService.getProductUsageMetrics(productId, dateRange);
10110
+ }
10111
+ async getProductUsageMetricsByEntity(groupBy, dateRange, filters) {
10112
+ return this.analyticsService.getProductUsageMetricsByEntity(groupBy, dateRange, filters);
10113
+ }
10114
+ async getPatientAnalytics(patientId, dateRange) {
10115
+ return this.analyticsService.getPatientAnalytics(patientId, dateRange);
10116
+ }
10117
+ async getPatientBehaviorMetricsByEntity(groupBy, dateRange, filters) {
10118
+ return this.analyticsService.getPatientBehaviorMetricsByEntity(groupBy, dateRange, filters);
10119
+ }
10120
+ async getClinicAnalytics(clinicBranchId, dateRange) {
10121
+ const dashboard = await this.analyticsService.getDashboardData(
10122
+ { clinicBranchId },
10123
+ dateRange
10124
+ );
10125
+ const clinicDoc = await this.db.collection("clinics").doc(clinicBranchId).get();
10126
+ const clinicData = clinicDoc.data();
10127
+ const clinicName = (clinicData == null ? void 0 : clinicData.name) || "Unknown";
10128
+ return {
10129
+ clinicBranchId,
10130
+ clinicName,
10131
+ totalAppointments: dashboard.overview.totalAppointments,
10132
+ completedAppointments: dashboard.overview.completedAppointments,
10133
+ canceledAppointments: dashboard.overview.canceledAppointments,
10134
+ noShowAppointments: dashboard.overview.noShowAppointments,
10135
+ cancellationRate: dashboard.overview.cancellationRate,
10136
+ noShowRate: dashboard.overview.noShowRate,
10137
+ totalRevenue: dashboard.overview.totalRevenue,
10138
+ averageRevenuePerAppointment: dashboard.overview.averageRevenuePerAppointment,
10139
+ currency: dashboard.overview.currency,
10140
+ practitionerCount: dashboard.overview.uniquePractitioners,
10141
+ patientCount: dashboard.overview.uniquePatients,
10142
+ procedureCount: dashboard.overview.uniqueProcedures,
10143
+ topPractitioners: dashboard.practitionerMetrics.slice(0, 5).map((p) => ({
10144
+ practitionerId: p.practitionerId,
10145
+ practitionerName: p.practitionerName,
10146
+ appointmentCount: p.totalAppointments,
10147
+ revenue: p.totalRevenue
10148
+ })),
10149
+ topProcedures: dashboard.procedureMetrics.slice(0, 5).map((p) => ({
10150
+ procedureId: p.procedureId,
10151
+ procedureName: p.procedureName,
10152
+ appointmentCount: p.totalAppointments,
10153
+ revenue: p.totalRevenue
10154
+ }))
10155
+ };
10156
+ }
10157
+ async getDashboardData(filters, dateRange, options) {
10158
+ return this.analyticsService.getDashboardData(filters, dateRange, options);
10159
+ }
10160
+ /**
10161
+ * Expose fetchAppointments for direct access if needed
10162
+ * This method is used internally by AnalyticsService
10163
+ */
10164
+ async fetchAppointments(filters, dateRange) {
10165
+ return this.analyticsService.fetchAppointments(filters, dateRange);
10166
+ }
10167
+ };
10168
+
10169
+ // src/admin/booking/booking.calculator.ts
10170
+ var import_firestore5 = require("firebase/firestore");
10171
+ var import_luxon2 = require("luxon");
10172
+ var BookingAvailabilityCalculator = class {
10173
+ /**
10174
+ * Calculate available booking slots based on the provided data
10175
+ *
10176
+ * @param request - The request containing all necessary data for calculation
10177
+ * @returns Response with available booking slots
10178
+ */
10179
+ static calculateSlots(request) {
10180
+ const {
10181
+ clinic,
10182
+ practitioner,
10183
+ procedure,
10184
+ timeframe,
10185
+ clinicCalendarEvents,
10186
+ practitionerCalendarEvents,
10187
+ tz
10188
+ } = request;
10189
+ const schedulingIntervalMinutes = clinic.schedulingInterval || this.DEFAULT_INTERVAL_MINUTES;
10190
+ const procedureDurationMinutes = procedure.duration;
10191
+ console.log(
10192
+ `Calculating slots with interval: ${schedulingIntervalMinutes}min and procedure duration: ${procedureDurationMinutes}min`
10193
+ );
10194
+ let availableIntervals = [
10195
+ { start: timeframe.start, end: timeframe.end }
10196
+ ];
10197
+ availableIntervals = this.applyClinicWorkingHours(
10198
+ availableIntervals,
10199
+ clinic.workingHours,
10200
+ timeframe,
10201
+ tz
10202
+ );
10203
+ availableIntervals = this.subtractBlockingEvents(
10204
+ availableIntervals,
10205
+ clinicCalendarEvents
10206
+ );
10207
+ availableIntervals = this.applyPractitionerWorkingHours(
10208
+ availableIntervals,
10209
+ practitioner,
10210
+ clinic.id,
10211
+ timeframe,
10212
+ tz
10213
+ );
10214
+ availableIntervals = this.subtractPractitionerBusyTimes(
10215
+ availableIntervals,
10216
+ practitionerCalendarEvents
10217
+ );
10218
+ console.log(
10219
+ `After all filters, have ${availableIntervals.length} available intervals`
10220
+ );
10221
+ const availableSlots = this.generateAvailableSlots(
10222
+ availableIntervals,
10223
+ schedulingIntervalMinutes,
10224
+ procedureDurationMinutes,
10225
+ tz
10226
+ );
10227
+ return { availableSlots };
10228
+ }
10229
+ /**
10230
+ * Apply clinic working hours to available intervals
10231
+ *
10232
+ * @param intervals - Current available intervals
10233
+ * @param workingHours - Clinic working hours
10234
+ * @param timeframe - Overall timeframe being considered
10235
+ * @param tz - IANA timezone of the clinic
10236
+ * @returns Intervals filtered by clinic working hours
10237
+ */
10238
+ static applyClinicWorkingHours(intervals, workingHours, timeframe, tz) {
10239
+ if (!intervals.length) return [];
10240
+ console.log(
10241
+ `Applying clinic working hours to ${intervals.length} intervals`
10242
+ );
10243
+ const workingIntervals = this.createWorkingHoursIntervals(
10244
+ workingHours,
10245
+ timeframe.start.toDate(),
10246
+ timeframe.end.toDate(),
10247
+ tz
10248
+ );
10249
+ return this.intersectIntervals(intervals, workingIntervals);
10250
+ }
10251
+ /**
10252
+ * Create time intervals for working hours across multiple days
10253
+ *
10254
+ * @param workingHours - Working hours definition
10255
+ * @param startDate - Start date of the overall timeframe
10256
+ * @param endDate - End date of the overall timeframe
10257
+ * @param tz - IANA timezone of the clinic
10258
+ * @returns Array of time intervals representing working hours
10259
+ */
10260
+ static createWorkingHoursIntervals(workingHours, startDate, endDate, tz) {
10261
+ const workingIntervals = [];
10262
+ let start = import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz });
10263
+ const end = import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz });
10264
+ while (start <= end) {
10265
+ const dayOfWeek = start.weekday;
10266
+ const dayName = [
10267
+ "monday",
10268
+ "tuesday",
10269
+ "wednesday",
10270
+ "thursday",
10271
+ "friday",
10272
+ "saturday",
10273
+ "sunday"
10274
+ ][dayOfWeek - 1];
10275
+ if (dayName && workingHours[dayName]) {
10276
+ const daySchedule = workingHours[dayName];
10277
+ if (daySchedule) {
10278
+ const [openHours, openMinutes] = daySchedule.open.split(":").map(Number);
10279
+ const [closeHours, closeMinutes] = daySchedule.close.split(":").map(Number);
10280
+ let workStart = start.set({
10281
+ hour: openHours,
10282
+ minute: openMinutes,
10283
+ second: 0,
10284
+ millisecond: 0
10285
+ });
10286
+ let workEnd = start.set({
10287
+ hour: closeHours,
10288
+ minute: closeMinutes,
10289
+ second: 0,
10290
+ millisecond: 0
10291
+ });
10292
+ if (workEnd.toMillis() > startDate.getTime() && workStart.toMillis() < endDate.getTime()) {
10293
+ const intervalStart = workStart < import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz }) ? import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
10294
+ const intervalEnd = workEnd > import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz }) ? import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
10295
+ workingIntervals.push({
10296
+ start: import_firestore5.Timestamp.fromMillis(intervalStart.toMillis()),
10297
+ end: import_firestore5.Timestamp.fromMillis(intervalEnd.toMillis())
10298
+ });
10299
+ if (daySchedule.breaks && daySchedule.breaks.length > 0) {
10300
+ for (const breakTime of daySchedule.breaks) {
10301
+ const [breakStartHours, breakStartMinutes] = breakTime.start.split(":").map(Number);
10302
+ const [breakEndHours, breakEndMinutes] = breakTime.end.split(":").map(Number);
10303
+ const breakStart = start.set({
10304
+ hour: breakStartHours,
10305
+ minute: breakStartMinutes
10306
+ });
10307
+ const breakEnd = start.set({
10308
+ hour: breakEndHours,
10309
+ minute: breakEndMinutes
10310
+ });
10311
+ workingIntervals.splice(
10312
+ -1,
10313
+ 1,
10314
+ ...this.subtractInterval(
10315
+ workingIntervals[workingIntervals.length - 1],
10316
+ {
10317
+ start: import_firestore5.Timestamp.fromMillis(breakStart.toMillis()),
10318
+ end: import_firestore5.Timestamp.fromMillis(breakEnd.toMillis())
10319
+ }
10320
+ )
10321
+ );
10322
+ }
10323
+ }
10324
+ }
10325
+ }
10326
+ }
10327
+ start = start.plus({ days: 1 });
10328
+ }
10329
+ return workingIntervals;
10330
+ }
10331
+ /**
10332
+ * Subtract blocking events from available intervals
10333
+ *
10334
+ * @param intervals - Current available intervals
10335
+ * @param events - Calendar events to subtract
10336
+ * @returns Available intervals after removing blocking events
10337
+ */
10338
+ static subtractBlockingEvents(intervals, events) {
10339
+ if (!intervals.length) return [];
10340
+ console.log(`Subtracting ${events.length} blocking events`);
10341
+ const blockingEvents = events.filter(
10342
+ (event) => event.eventType === "blocking" /* BLOCKING */ || event.eventType === "break" /* BREAK */ || event.eventType === "free_day" /* FREE_DAY */
10343
+ );
10344
+ let result = [...intervals];
10345
+ for (const event of blockingEvents) {
10346
+ const { start, end } = event.eventTime;
10347
+ const blockingInterval = { start, end };
10348
+ const newResult = [];
10349
+ for (const interval of result) {
10350
+ const remainingIntervals = this.subtractInterval(
10351
+ interval,
10352
+ blockingInterval
10353
+ );
10354
+ newResult.push(...remainingIntervals);
10355
+ }
10356
+ result = newResult;
10357
+ }
10358
+ return result;
10359
+ }
10360
+ /**
10361
+ * Apply practitioner's specific working hours for the given clinic
10362
+ *
10363
+ * @param intervals - Current available intervals
10364
+ * @param practitioner - Practitioner object
10365
+ * @param clinicId - ID of the clinic
10366
+ * @param timeframe - Overall timeframe being considered
10367
+ * @param tz - IANA timezone of the clinic
10368
+ * @returns Intervals filtered by practitioner's working hours
10369
+ */
10370
+ static applyPractitionerWorkingHours(intervals, practitioner, clinicId, timeframe, tz) {
10371
+ if (!intervals.length) return [];
10372
+ console.log(`Applying practitioner working hours for clinic ${clinicId}`);
10373
+ const clinicWorkingHours = practitioner.clinicWorkingHours.find(
10374
+ (hours) => hours.clinicId === clinicId && hours.isActive
10375
+ );
10376
+ if (!clinicWorkingHours) {
10377
+ console.log(
10378
+ `No working hours found for practitioner at clinic ${clinicId}`
10379
+ );
10380
+ return [];
10381
+ }
10382
+ const workingIntervals = this.createPractitionerWorkingHoursIntervals(
10383
+ clinicWorkingHours.workingHours,
10384
+ timeframe.start.toDate(),
10385
+ timeframe.end.toDate(),
10386
+ tz
10387
+ );
7151
10388
  return this.intersectIntervals(intervals, workingIntervals);
7152
10389
  }
7153
10390
  /**
@@ -7188,8 +10425,8 @@ var BookingAvailabilityCalculator = class {
7188
10425
  const intervalStart = workStart < import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz }) ? import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
7189
10426
  const intervalEnd = workEnd > import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz }) ? import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
7190
10427
  workingIntervals.push({
7191
- start: import_firestore2.Timestamp.fromMillis(intervalStart.toMillis()),
7192
- end: import_firestore2.Timestamp.fromMillis(intervalEnd.toMillis())
10428
+ start: import_firestore5.Timestamp.fromMillis(intervalStart.toMillis()),
10429
+ end: import_firestore5.Timestamp.fromMillis(intervalEnd.toMillis())
7193
10430
  });
7194
10431
  }
7195
10432
  }
@@ -7269,7 +10506,7 @@ var BookingAvailabilityCalculator = class {
7269
10506
  const isInFuture = slotStart >= earliestBookableTime;
7270
10507
  if (isInFuture && this.isSlotFullyAvailable(slotStart, slotEnd, intervals, tz)) {
7271
10508
  slots.push({
7272
- start: import_firestore2.Timestamp.fromMillis(slotStart.toMillis())
10509
+ start: import_firestore5.Timestamp.fromMillis(slotStart.toMillis())
7273
10510
  });
7274
10511
  }
7275
10512
  slotStart = slotStart.plus({ minutes: intervalMinutes });
@@ -7388,13 +10625,13 @@ var BookingAvailabilityCalculator = class {
7388
10625
  BookingAvailabilityCalculator.DEFAULT_INTERVAL_MINUTES = 15;
7389
10626
 
7390
10627
  // src/admin/booking/booking.admin.ts
7391
- var admin15 = __toESM(require("firebase-admin"));
10628
+ var admin16 = __toESM(require("firebase-admin"));
7392
10629
 
7393
10630
  // src/admin/documentation-templates/document-manager.admin.ts
7394
- var admin14 = __toESM(require("firebase-admin"));
10631
+ var admin15 = __toESM(require("firebase-admin"));
7395
10632
  var DocumentManagerAdminService = class {
7396
- constructor(firestore18) {
7397
- this.db = firestore18;
10633
+ constructor(firestore19) {
10634
+ this.db = firestore19;
7398
10635
  }
7399
10636
  /**
7400
10637
  * Adds operations to a Firestore batch to initialize all linked forms for a new appointment
@@ -7497,10 +10734,10 @@ var DocumentManagerAdminService = class {
7497
10734
  };
7498
10735
  }
7499
10736
  const templateIds = technologyTemplates.map((t) => t.templateId);
7500
- const templatesSnapshot = await this.db.collection(DOCUMENTATION_TEMPLATES_COLLECTION).where(admin14.firestore.FieldPath.documentId(), "in", templateIds).get();
10737
+ const templatesSnapshot = await this.db.collection(DOCUMENTATION_TEMPLATES_COLLECTION).where(admin15.firestore.FieldPath.documentId(), "in", templateIds).get();
7501
10738
  const templatesMap = /* @__PURE__ */ new Map();
7502
- templatesSnapshot.forEach((doc) => {
7503
- templatesMap.set(doc.id, doc.data());
10739
+ templatesSnapshot.forEach((doc3) => {
10740
+ templatesMap.set(doc3.id, doc3.data());
7504
10741
  });
7505
10742
  for (const templateRef of technologyTemplates) {
7506
10743
  const template = templatesMap.get(templateRef.templateId);
@@ -7563,8 +10800,8 @@ var BookingAdmin = class {
7563
10800
  * Creates a new BookingAdmin instance
7564
10801
  * @param firestore - Firestore instance provided by the caller
7565
10802
  */
7566
- constructor(firestore18) {
7567
- this.db = firestore18 || admin15.firestore();
10803
+ constructor(firestore19) {
10804
+ this.db = firestore19 || admin16.firestore();
7568
10805
  this.documentManagerAdmin = new DocumentManagerAdminService(this.db);
7569
10806
  }
7570
10807
  /**
@@ -7586,8 +10823,8 @@ var BookingAdmin = class {
7586
10823
  timeframeStart: timeframe.start instanceof Date ? timeframe.start.toISOString() : timeframe.start.toDate().toISOString(),
7587
10824
  timeframeEnd: timeframe.end instanceof Date ? timeframe.end.toISOString() : timeframe.end.toDate().toISOString()
7588
10825
  });
7589
- const start = timeframe.start instanceof Date ? admin15.firestore.Timestamp.fromDate(timeframe.start) : timeframe.start;
7590
- const end = timeframe.end instanceof Date ? admin15.firestore.Timestamp.fromDate(timeframe.end) : timeframe.end;
10826
+ const start = timeframe.start instanceof Date ? admin16.firestore.Timestamp.fromDate(timeframe.start) : timeframe.start;
10827
+ const end = timeframe.end instanceof Date ? admin16.firestore.Timestamp.fromDate(timeframe.end) : timeframe.end;
7591
10828
  Logger.debug("[BookingAdmin] Fetching clinic data", { clinicId });
7592
10829
  const clinicDoc = await this.db.collection("clinics").doc(clinicId).get();
7593
10830
  if (!clinicDoc.exists) {
@@ -7673,7 +10910,7 @@ var BookingAdmin = class {
7673
10910
  const result = BookingAvailabilityCalculator.calculateSlots(request);
7674
10911
  const availableSlotsResult = {
7675
10912
  availableSlots: result.availableSlots.map((slot) => ({
7676
- start: admin15.firestore.Timestamp.fromMillis(slot.start.toMillis())
10913
+ start: admin16.firestore.Timestamp.fromMillis(slot.start.toMillis())
7677
10914
  }))
7678
10915
  };
7679
10916
  Logger.info(
@@ -7736,14 +10973,14 @@ var BookingAdmin = class {
7736
10973
  endTime: end.toDate().toISOString()
7737
10974
  });
7738
10975
  const MAX_EVENT_DURATION_MS = 24 * 60 * 60 * 1e3;
7739
- const queryStart = admin15.firestore.Timestamp.fromMillis(
10976
+ const queryStart = admin16.firestore.Timestamp.fromMillis(
7740
10977
  start.toMillis() - MAX_EVENT_DURATION_MS
7741
10978
  );
7742
10979
  const eventsRef = this.db.collection(`clinics/${clinicId}/calendar`).where("eventTime.start", ">=", queryStart).where("eventTime.start", "<", end).orderBy("eventTime.start");
7743
10980
  const snapshot = await eventsRef.get();
7744
- const events = snapshot.docs.map((doc) => ({
7745
- ...doc.data(),
7746
- id: doc.id
10981
+ const events = snapshot.docs.map((doc3) => ({
10982
+ ...doc3.data(),
10983
+ id: doc3.id
7747
10984
  })).filter((event) => {
7748
10985
  return event.eventTime.end.toMillis() > start.toMillis();
7749
10986
  });
@@ -7782,14 +11019,14 @@ var BookingAdmin = class {
7782
11019
  endTime: end.toDate().toISOString()
7783
11020
  });
7784
11021
  const MAX_EVENT_DURATION_MS = 24 * 60 * 60 * 1e3;
7785
- const queryStart = admin15.firestore.Timestamp.fromMillis(
11022
+ const queryStart = admin16.firestore.Timestamp.fromMillis(
7786
11023
  start.toMillis() - MAX_EVENT_DURATION_MS
7787
11024
  );
7788
11025
  const eventsRef = this.db.collection(`practitioners/${practitionerId}/calendar`).where("eventTime.start", ">=", queryStart).where("eventTime.start", "<", end).orderBy("eventTime.start");
7789
11026
  const snapshot = await eventsRef.get();
7790
- const events = snapshot.docs.map((doc) => ({
7791
- ...doc.data(),
7792
- id: doc.id
11027
+ const events = snapshot.docs.map((doc3) => ({
11028
+ ...doc3.data(),
11029
+ id: doc3.id
7793
11030
  })).filter((event) => {
7794
11031
  return event.eventTime.end.toMillis() > start.toMillis();
7795
11032
  });
@@ -7851,8 +11088,8 @@ var BookingAdmin = class {
7851
11088
  `[BookingAdmin] Orchestrating appointment creation for patient ${data.patientId} by user ${authenticatedUserId}`
7852
11089
  );
7853
11090
  const batch = this.db.batch();
7854
- const adminTsNow = admin15.firestore.Timestamp.now();
7855
- const serverTimestampValue = admin15.firestore.FieldValue.serverTimestamp();
11091
+ const adminTsNow = admin16.firestore.Timestamp.now();
11092
+ const serverTimestampValue = admin16.firestore.FieldValue.serverTimestamp();
7856
11093
  try {
7857
11094
  if (!data.patientId || !data.procedureId || !data.appointmentStartTime || !data.appointmentEndTime) {
7858
11095
  return {
@@ -7949,7 +11186,7 @@ var BookingAdmin = class {
7949
11186
  fullName: `${(patientSensitiveData == null ? void 0 : patientSensitiveData.firstName) || ""} ${(patientSensitiveData == null ? void 0 : patientSensitiveData.lastName) || ""}`.trim() || patientProfileData.displayName,
7950
11187
  email: (patientSensitiveData == null ? void 0 : patientSensitiveData.email) || "",
7951
11188
  phone: (patientSensitiveData == null ? void 0 : patientSensitiveData.phoneNumber) || patientProfileData.phoneNumber || null,
7952
- dateOfBirth: (patientSensitiveData == null ? void 0 : patientSensitiveData.dateOfBirth) || patientProfileData.dateOfBirth || admin15.firestore.Timestamp.now(),
11189
+ dateOfBirth: (patientSensitiveData == null ? void 0 : patientSensitiveData.dateOfBirth) || patientProfileData.dateOfBirth || admin16.firestore.Timestamp.now(),
7953
11190
  gender: (patientSensitiveData == null ? void 0 : patientSensitiveData.gender) || "other" /* OTHER */
7954
11191
  };
7955
11192
  const newAppointmentId = this.db.collection(APPOINTMENTS_COLLECTION).doc().id;
@@ -8214,7 +11451,7 @@ var BookingAdmin = class {
8214
11451
  };
8215
11452
 
8216
11453
  // src/admin/free-consultation/free-consultation-utils.admin.ts
8217
- var admin16 = __toESM(require("firebase-admin"));
11454
+ var admin17 = __toESM(require("firebase-admin"));
8218
11455
 
8219
11456
  // src/backoffice/types/category.types.ts
8220
11457
  var CATEGORIES_COLLECTION = "backoffice_categories";
@@ -8227,10 +11464,10 @@ var TECHNOLOGIES_COLLECTION = "technologies";
8227
11464
 
8228
11465
  // src/admin/free-consultation/free-consultation-utils.admin.ts
8229
11466
  async function freeConsultationInfrastructure(db) {
8230
- const firestore18 = db || admin16.firestore();
11467
+ const firestore19 = db || admin17.firestore();
8231
11468
  try {
8232
11469
  console.log("[freeConsultationInfrastructure] Checking free consultation infrastructure...");
8233
- const technologyRef = firestore18.collection(TECHNOLOGIES_COLLECTION).doc("free-consultation-tech");
11470
+ const technologyRef = firestore19.collection(TECHNOLOGIES_COLLECTION).doc("free-consultation-tech");
8234
11471
  const technologyDoc = await technologyRef.get();
8235
11472
  if (technologyDoc.exists) {
8236
11473
  console.log(
@@ -8239,7 +11476,7 @@ async function freeConsultationInfrastructure(db) {
8239
11476
  return true;
8240
11477
  }
8241
11478
  console.log("[freeConsultationInfrastructure] Creating free consultation infrastructure...");
8242
- await createFreeConsultationInfrastructure(firestore18);
11479
+ await createFreeConsultationInfrastructure(firestore19);
8243
11480
  console.log(
8244
11481
  "[freeConsultationInfrastructure] Successfully created free consultation infrastructure"
8245
11482
  );
@@ -8436,8 +11673,8 @@ var PractitionerInviteMailingService = class extends BaseMailingService {
8436
11673
  * @param firestore Firestore instance provided by the caller
8437
11674
  * @param mailgunClient Mailgun client instance (mailgun.js v10+) provided by the caller
8438
11675
  */
8439
- constructor(firestore18, mailgunClient) {
8440
- super(firestore18, mailgunClient);
11676
+ constructor(firestore19, mailgunClient) {
11677
+ super(firestore19, mailgunClient);
8441
11678
  this.DEFAULT_REGISTRATION_URL = "https://metaesthetics.net/register";
8442
11679
  this.DEFAULT_SUBJECT = "You've Been Invited to Join as a Practitioner";
8443
11680
  this.DEFAULT_MAILGUN_DOMAIN = "mg.metaesthetics.net";
@@ -9300,8 +12537,8 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
9300
12537
  * @param firestore Firestore instance provided by the caller
9301
12538
  * @param mailgunClient Mailgun client instance (mailgun.js v10+) provided by the caller
9302
12539
  */
9303
- constructor(firestore18, mailgunClient) {
9304
- super(firestore18, mailgunClient);
12540
+ constructor(firestore19, mailgunClient) {
12541
+ super(firestore19, mailgunClient);
9305
12542
  this.DEFAULT_MAILGUN_DOMAIN = "mg.metaesthetics.net";
9306
12543
  this.DEFAULT_FROM_ADDRESS = "MetaEstetics <no-reply@mg.metaesthetics.net>";
9307
12544
  }
@@ -9644,8 +12881,8 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
9644
12881
  */
9645
12882
  async fetchPractitionerById(practitionerId) {
9646
12883
  try {
9647
- const doc = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
9648
- return doc.exists ? doc.data() : null;
12884
+ const doc3 = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
12885
+ return doc3.exists ? doc3.data() : null;
9649
12886
  } catch (error) {
9650
12887
  Logger.error(
9651
12888
  "[ExistingPractitionerInviteMailingService] Error fetching practitioner:",
@@ -9661,8 +12898,8 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
9661
12898
  */
9662
12899
  async fetchClinicById(clinicId) {
9663
12900
  try {
9664
- const doc = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
9665
- return doc.exists ? doc.data() : null;
12901
+ const doc3 = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
12902
+ return doc3.exists ? doc3.data() : null;
9666
12903
  } catch (error) {
9667
12904
  Logger.error(
9668
12905
  "[ExistingPractitionerInviteMailingService] Error fetching clinic:",
@@ -9674,14 +12911,14 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
9674
12911
  };
9675
12912
 
9676
12913
  // src/admin/users/user-profile.admin.ts
9677
- var admin17 = __toESM(require("firebase-admin"));
12914
+ var admin18 = __toESM(require("firebase-admin"));
9678
12915
  var UserProfileAdminService = class {
9679
12916
  /**
9680
12917
  * Constructor for UserProfileAdminService
9681
12918
  * @param firestore Optional Firestore instance. If not provided, uses the default admin SDK instance.
9682
12919
  */
9683
- constructor(firestore18) {
9684
- this.db = firestore18 || admin17.firestore();
12920
+ constructor(firestore19) {
12921
+ this.db = firestore19 || admin18.firestore();
9685
12922
  }
9686
12923
  /**
9687
12924
  * Creates a blank user profile with minimal information
@@ -9698,9 +12935,9 @@ var UserProfileAdminService = class {
9698
12935
  roles: [],
9699
12936
  // Empty roles array as requested
9700
12937
  isAnonymous: authUserData.isAnonymous,
9701
- createdAt: admin17.firestore.FieldValue.serverTimestamp(),
9702
- updatedAt: admin17.firestore.FieldValue.serverTimestamp(),
9703
- lastLoginAt: admin17.firestore.FieldValue.serverTimestamp()
12938
+ createdAt: admin18.firestore.FieldValue.serverTimestamp(),
12939
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp(),
12940
+ lastLoginAt: admin18.firestore.FieldValue.serverTimestamp()
9704
12941
  };
9705
12942
  try {
9706
12943
  const userRef = this.db.collection(USERS_COLLECTION).doc(authUserData.uid);
@@ -9776,8 +13013,8 @@ var UserProfileAdminService = class {
9776
13013
  clinics: mergedProfileData.clinics || [],
9777
13014
  doctorIds: mergedProfileData.doctorIds || [],
9778
13015
  clinicIds: mergedProfileData.clinicIds || [],
9779
- createdAt: admin17.firestore.FieldValue.serverTimestamp(),
9780
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
13016
+ createdAt: admin18.firestore.FieldValue.serverTimestamp(),
13017
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp()
9781
13018
  };
9782
13019
  await patientProfileRef.set(patientProfileData);
9783
13020
  patientProfile = {
@@ -9816,8 +13053,8 @@ var UserProfileAdminService = class {
9816
13053
  };
9817
13054
  const sensitiveInfoData = {
9818
13055
  ...mergedSensitiveData,
9819
- createdAt: admin17.firestore.FieldValue.serverTimestamp(),
9820
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
13056
+ createdAt: admin18.firestore.FieldValue.serverTimestamp(),
13057
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp()
9821
13058
  // Leave dateOfBirth as is
9822
13059
  };
9823
13060
  await sensitiveInfoRef.set(sensitiveInfoData);
@@ -9843,7 +13080,7 @@ var UserProfileAdminService = class {
9843
13080
  contraindications: [],
9844
13081
  allergies: [],
9845
13082
  currentMedications: [],
9846
- lastUpdated: admin17.firestore.FieldValue.serverTimestamp(),
13083
+ lastUpdated: admin18.firestore.FieldValue.serverTimestamp(),
9847
13084
  updatedBy: userId
9848
13085
  };
9849
13086
  await medicalInfoRef.set(medicalInfoData);
@@ -9860,14 +13097,14 @@ var UserProfileAdminService = class {
9860
13097
  const batch = this.db.batch();
9861
13098
  if (!userData.roles.includes("patient" /* PATIENT */)) {
9862
13099
  batch.update(userRef, {
9863
- roles: admin17.firestore.FieldValue.arrayUnion("patient" /* PATIENT */),
9864
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
13100
+ roles: admin18.firestore.FieldValue.arrayUnion("patient" /* PATIENT */),
13101
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp()
9865
13102
  });
9866
13103
  }
9867
13104
  if (!userData.patientProfile) {
9868
13105
  batch.update(userRef, {
9869
13106
  patientProfile: patientProfileId,
9870
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
13107
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp()
9871
13108
  });
9872
13109
  }
9873
13110
  await batch.commit();
@@ -9908,8 +13145,8 @@ var UserProfileAdminService = class {
9908
13145
  const userData = userDoc.data();
9909
13146
  if (!userData.roles.includes("clinic_admin" /* CLINIC_ADMIN */)) {
9910
13147
  await userRef.update({
9911
- roles: admin17.firestore.FieldValue.arrayUnion("clinic_admin" /* CLINIC_ADMIN */),
9912
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
13148
+ roles: admin18.firestore.FieldValue.arrayUnion("clinic_admin" /* CLINIC_ADMIN */),
13149
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp()
9913
13150
  });
9914
13151
  }
9915
13152
  const updatedUserDoc = await userRef.get();
@@ -9940,8 +13177,8 @@ var UserProfileAdminService = class {
9940
13177
  const userData = userDoc.data();
9941
13178
  if (!userData.roles.includes("practitioner" /* PRACTITIONER */)) {
9942
13179
  await userRef.update({
9943
- roles: admin17.firestore.FieldValue.arrayUnion("practitioner" /* PRACTITIONER */),
9944
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
13180
+ roles: admin18.firestore.FieldValue.arrayUnion("practitioner" /* PRACTITIONER */),
13181
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp()
9945
13182
  });
9946
13183
  }
9947
13184
  const updatedUserDoc = await userRef.get();
@@ -9961,6 +13198,8 @@ console.log("[Admin Module] Initialized and services exported.");
9961
13198
  TimestampUtils.enableServerMode();
9962
13199
  // Annotate the CommonJS export names for ESM import in node:
9963
13200
  0 && (module.exports = {
13201
+ ANALYTICS_COLLECTION,
13202
+ AnalyticsAdminService,
9964
13203
  AppointmentAggregationService,
9965
13204
  AppointmentMailingService,
9966
13205
  AppointmentStatus,
@@ -9968,19 +13207,26 @@ TimestampUtils.enableServerMode();
9968
13207
  BillingTransactionType,
9969
13208
  BookingAdmin,
9970
13209
  BookingAvailabilityCalculator,
13210
+ CANCELLATION_ANALYTICS_SUBCOLLECTION,
13211
+ CLINICS_COLLECTION,
13212
+ CLINIC_ANALYTICS_SUBCOLLECTION,
9971
13213
  CalendarAdminService,
9972
13214
  ClinicAggregationService,
13215
+ DASHBOARD_ANALYTICS_SUBCOLLECTION,
9973
13216
  DocumentManagerAdminService,
9974
13217
  ExistingPractitionerInviteMailingService,
9975
13218
  FilledFormsAggregationService,
9976
13219
  Logger,
9977
13220
  NOTIFICATIONS_COLLECTION,
13221
+ NO_SHOW_ANALYTICS_SUBCOLLECTION,
9978
13222
  NotificationStatus,
9979
13223
  NotificationType,
9980
13224
  NotificationsAdmin,
9981
13225
  PATIENTS_COLLECTION,
9982
13226
  PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME,
9983
13227
  PATIENT_SENSITIVE_INFO_COLLECTION,
13228
+ PRACTITIONER_ANALYTICS_SUBCOLLECTION,
13229
+ PROCEDURE_ANALYTICS_SUBCOLLECTION,
9984
13230
  PatientAggregationService,
9985
13231
  PatientInstructionStatus,
9986
13232
  PatientRequirementOverallStatus,
@@ -9991,8 +13237,10 @@ TimestampUtils.enableServerMode();
9991
13237
  PractitionerInviteStatus,
9992
13238
  PractitionerTokenStatus,
9993
13239
  ProcedureAggregationService,
13240
+ REVENUE_ANALYTICS_SUBCOLLECTION,
9994
13241
  ReviewsAggregationService,
9995
13242
  SubscriptionStatus,
13243
+ TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION,
9996
13244
  UserProfileAdminService,
9997
13245
  UserRole,
9998
13246
  freeConsultationInfrastructure