@blackcode_sa/metaestetics-api 1.12.67 → 1.13.0

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 (47) hide show
  1. package/dist/admin/index.d.mts +801 -2
  2. package/dist/admin/index.d.ts +801 -2
  3. package/dist/admin/index.js +2332 -153
  4. package/dist/admin/index.mjs +2321 -153
  5. package/dist/backoffice/index.d.mts +40 -0
  6. package/dist/backoffice/index.d.ts +40 -0
  7. package/dist/backoffice/index.js +118 -18
  8. package/dist/backoffice/index.mjs +118 -20
  9. package/dist/index.d.mts +1097 -2
  10. package/dist/index.d.ts +1097 -2
  11. package/dist/index.js +4224 -2091
  12. package/dist/index.mjs +3941 -1821
  13. package/package.json +1 -1
  14. package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +140 -0
  15. package/src/admin/analytics/analytics.admin.service.ts +278 -0
  16. package/src/admin/analytics/index.ts +2 -0
  17. package/src/admin/index.ts +6 -0
  18. package/src/backoffice/services/README.md +17 -0
  19. package/src/backoffice/services/analytics.service.proposal.md +863 -0
  20. package/src/backoffice/services/analytics.service.summary.md +143 -0
  21. package/src/backoffice/services/category.service.ts +49 -6
  22. package/src/backoffice/services/subcategory.service.ts +50 -6
  23. package/src/backoffice/services/technology.service.ts +53 -6
  24. package/src/services/analytics/ARCHITECTURE.md +199 -0
  25. package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -0
  26. package/src/services/analytics/GROUPED_ANALYTICS.md +501 -0
  27. package/src/services/analytics/QUICK_START.md +393 -0
  28. package/src/services/analytics/README.md +287 -0
  29. package/src/services/analytics/SUMMARY.md +141 -0
  30. package/src/services/analytics/USAGE_GUIDE.md +518 -0
  31. package/src/services/analytics/analytics-cloud.service.ts +222 -0
  32. package/src/services/analytics/analytics.service.ts +1632 -0
  33. package/src/services/analytics/index.ts +3 -0
  34. package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -0
  35. package/src/services/analytics/utils/cost-calculation.utils.ts +154 -0
  36. package/src/services/analytics/utils/grouping.utils.ts +394 -0
  37. package/src/services/analytics/utils/stored-analytics.utils.ts +347 -0
  38. package/src/services/analytics/utils/time-calculation.utils.ts +186 -0
  39. package/src/services/appointment/appointment.service.ts +50 -6
  40. package/src/services/index.ts +1 -0
  41. package/src/services/procedure/procedure.service.ts +3 -3
  42. package/src/types/analytics/analytics.types.ts +500 -0
  43. package/src/types/analytics/grouped-analytics.types.ts +148 -0
  44. package/src/types/analytics/index.ts +4 -0
  45. package/src/types/analytics/stored-analytics.types.ts +137 -0
  46. package/src/types/index.ts +3 -0
  47. package/src/types/notifications/index.ts +21 -0
@@ -427,6 +427,7 @@ var NotificationType = /* @__PURE__ */ ((NotificationType2) => {
427
427
  NotificationType2["FORM_REMINDER"] = "formReminder";
428
428
  NotificationType2["FORM_SUBMISSION_CONFIRMATION"] = "formSubmissionConfirmation";
429
429
  NotificationType2["REVIEW_REQUEST"] = "reviewRequest";
430
+ NotificationType2["PROCEDURE_RECOMMENDATION"] = "procedureRecommendation";
430
431
  NotificationType2["PAYMENT_DUE"] = "paymentDue";
431
432
  NotificationType2["PAYMENT_CONFIRMATION"] = "paymentConfirmation";
432
433
  NotificationType2["PAYMENT_FAILED"] = "paymentFailed";
@@ -480,6 +481,14 @@ var AppointmentStatus = /* @__PURE__ */ ((AppointmentStatus2) => {
480
481
  AppointmentStatus2["RESCHEDULED_BY_CLINIC"] = "rescheduled_by_clinic";
481
482
  return AppointmentStatus2;
482
483
  })(AppointmentStatus || {});
484
+ var PaymentStatus = /* @__PURE__ */ ((PaymentStatus2) => {
485
+ PaymentStatus2["UNPAID"] = "unpaid";
486
+ PaymentStatus2["PAID"] = "paid";
487
+ PaymentStatus2["PARTIALLY_PAID"] = "partially_paid";
488
+ PaymentStatus2["REFUNDED"] = "refunded";
489
+ PaymentStatus2["NOT_APPLICABLE"] = "not_applicable";
490
+ return PaymentStatus2;
491
+ })(PaymentStatus || {});
483
492
  var APPOINTMENTS_COLLECTION = "appointments";
484
493
 
485
494
  // src/types/patient/patient-requirements.ts
@@ -524,6 +533,17 @@ var DOCTOR_FORMS_SUBCOLLECTION = "doctor-forms";
524
533
  // src/types/reviews/index.ts
525
534
  var REVIEWS_COLLECTION = "reviews";
526
535
 
536
+ // src/types/analytics/stored-analytics.types.ts
537
+ var ANALYTICS_COLLECTION = "analytics";
538
+ var PRACTITIONER_ANALYTICS_SUBCOLLECTION = "practitioners";
539
+ var PROCEDURE_ANALYTICS_SUBCOLLECTION = "procedures";
540
+ var CLINIC_ANALYTICS_SUBCOLLECTION = "clinic";
541
+ var DASHBOARD_ANALYTICS_SUBCOLLECTION = "dashboard";
542
+ var TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION = "time_efficiency";
543
+ var CANCELLATION_ANALYTICS_SUBCOLLECTION = "cancellations";
544
+ var NO_SHOW_ANALYTICS_SUBCOLLECTION = "no_shows";
545
+ var REVENUE_ANALYTICS_SUBCOLLECTION = "revenue";
546
+
527
547
  // src/admin/notifications/notifications.admin.ts
528
548
  import * as admin2 from "firebase-admin";
529
549
  import { Expo } from "expo-server-sdk";
@@ -588,16 +608,16 @@ var Logger = class {
588
608
 
589
609
  // src/admin/notifications/notifications.admin.ts
590
610
  var NotificationsAdmin = class {
591
- constructor(firestore18) {
611
+ constructor(firestore19) {
592
612
  this.expo = new Expo();
593
- this.db = firestore18 || admin2.firestore();
613
+ this.db = firestore19 || admin2.firestore();
594
614
  }
595
615
  /**
596
616
  * Dohvata notifikaciju po ID-u
597
617
  */
598
618
  async getNotification(id) {
599
- const doc = await this.db.collection("notifications").doc(id).get();
600
- return doc.exists ? { id: doc.id, ...doc.data() } : null;
619
+ const doc2 = await this.db.collection("notifications").doc(id).get();
620
+ return doc2.exists ? { id: doc2.id, ...doc2.data() } : null;
601
621
  }
602
622
  /**
603
623
  * Kreira novu notifikaciju
@@ -784,10 +804,10 @@ var NotificationsAdmin = class {
784
804
  return;
785
805
  }
786
806
  const results = await Promise.allSettled(
787
- pendingNotifications.docs.map(async (doc) => {
807
+ pendingNotifications.docs.map(async (doc2) => {
788
808
  const notification = {
789
- id: doc.id,
790
- ...doc.data()
809
+ id: doc2.id,
810
+ ...doc2.data()
791
811
  };
792
812
  Logger.info(
793
813
  `[NotificationsAdmin] Processing notification ${notification.id} of type ${notification.notificationType}`
@@ -828,8 +848,8 @@ var NotificationsAdmin = class {
828
848
  break;
829
849
  }
830
850
  const batch = this.db.batch();
831
- oldNotifications.docs.forEach((doc) => {
832
- batch.delete(doc.ref);
851
+ oldNotifications.docs.forEach((doc2) => {
852
+ batch.delete(doc2.ref);
833
853
  });
834
854
  await batch.commit();
835
855
  totalDeleted += oldNotifications.size;
@@ -1099,8 +1119,8 @@ var NotificationsAdmin = class {
1099
1119
 
1100
1120
  // src/admin/requirements/patient-requirements.admin.service.ts
1101
1121
  var PatientRequirementsAdminService = class {
1102
- constructor(firestore18) {
1103
- this.db = firestore18 || admin3.firestore();
1122
+ constructor(firestore19) {
1123
+ this.db = firestore19 || admin3.firestore();
1104
1124
  this.notificationsAdmin = new NotificationsAdmin(this.db);
1105
1125
  }
1106
1126
  /**
@@ -1428,8 +1448,8 @@ var PatientRequirementsAdminService = class {
1428
1448
  // src/admin/calendar/calendar.admin.service.ts
1429
1449
  import * as admin4 from "firebase-admin";
1430
1450
  var CalendarAdminService = class {
1431
- constructor(firestore18) {
1432
- this.db = firestore18 || admin4.firestore();
1451
+ constructor(firestore19) {
1452
+ this.db = firestore19 || admin4.firestore();
1433
1453
  Logger.info("[CalendarAdminService] Initialized.");
1434
1454
  }
1435
1455
  /**
@@ -1715,9 +1735,9 @@ var BaseMailingService = class {
1715
1735
  * @param firestore Firestore instance provided by the caller
1716
1736
  * @param mailgunClient Mailgun client instance (mailgun.js v10+) provided by the caller
1717
1737
  */
1718
- constructor(firestore18, mailgunClient) {
1738
+ constructor(firestore19, mailgunClient) {
1719
1739
  var _a;
1720
- this.db = firestore18;
1740
+ this.db = firestore19;
1721
1741
  this.mailgunClient = mailgunClient;
1722
1742
  if (!this.db) {
1723
1743
  Logger.error("[BaseMailingService] No Firestore instance provided");
@@ -2259,8 +2279,8 @@ var clinicAppointmentRequestedTemplate = `
2259
2279
  </html>
2260
2280
  `;
2261
2281
  var AppointmentMailingService = class extends BaseMailingService {
2262
- constructor(firestore18, mailgunClient) {
2263
- super(firestore18, mailgunClient);
2282
+ constructor(firestore19, mailgunClient) {
2283
+ super(firestore19, mailgunClient);
2264
2284
  this.DEFAULT_MAILGUN_DOMAIN = "mg.metaesthetics.net";
2265
2285
  Logger.info("[AppointmentMailingService] Initialized.");
2266
2286
  }
@@ -2489,8 +2509,8 @@ var AppointmentAggregationService = class {
2489
2509
  * @param mailgunClient - An initialized Mailgun client instance.
2490
2510
  * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
2491
2511
  */
2492
- constructor(mailgunClient, firestore18) {
2493
- this.db = firestore18 || admin6.firestore();
2512
+ constructor(mailgunClient, firestore19) {
2513
+ this.db = firestore19 || admin6.firestore();
2494
2514
  this.appointmentMailingService = new AppointmentMailingService(
2495
2515
  this.db,
2496
2516
  mailgunClient
@@ -2866,6 +2886,11 @@ var AppointmentAggregationService = class {
2866
2886
  Logger.info(`[AggService] Zone photos changed for appointment ${after.id}`);
2867
2887
  await this.handleZonePhotosUpdate(before, after);
2868
2888
  }
2889
+ const recommendationsChanged = this.hasRecommendationsChanged(before, after);
2890
+ if (recommendationsChanged) {
2891
+ Logger.info(`[AggService] Recommended procedures changed for appointment ${after.id}`);
2892
+ await this.handleRecommendedProceduresUpdate(before, after, patientProfile);
2893
+ }
2869
2894
  Logger.info(`[AggService] Successfully processed UPDATE for appointment: ${after.id}`);
2870
2895
  } catch (error) {
2871
2896
  Logger.error(
@@ -3512,10 +3537,10 @@ var AppointmentAggregationService = class {
3512
3537
  }
3513
3538
  const batch = this.db.batch();
3514
3539
  let instancesUpdatedCount = 0;
3515
- instancesSnapshot.docs.forEach((doc) => {
3516
- const instance = doc.data();
3540
+ instancesSnapshot.docs.forEach((doc2) => {
3541
+ const instance = doc2.data();
3517
3542
  if (instance.overallStatus !== newOverallStatus && instance.overallStatus !== "failedToProcess" /* FAILED_TO_PROCESS */) {
3518
- batch.update(doc.ref, {
3543
+ batch.update(doc2.ref, {
3519
3544
  overallStatus: newOverallStatus,
3520
3545
  updatedAt: admin6.firestore.FieldValue.serverTimestamp()
3521
3546
  // Cast for now
@@ -3524,7 +3549,7 @@ var AppointmentAggregationService = class {
3524
3549
  });
3525
3550
  instancesUpdatedCount++;
3526
3551
  Logger.debug(
3527
- `[AggService] Added update for PatientRequirementInstance ${doc.id} to batch. New status: ${newOverallStatus}`
3552
+ `[AggService] Added update for PatientRequirementInstance ${doc2.id} to batch. New status: ${newOverallStatus}`
3528
3553
  );
3529
3554
  }
3530
3555
  });
@@ -3699,8 +3724,8 @@ var AppointmentAggregationService = class {
3699
3724
  // --- Data Fetching Helpers (Consider moving to a data access layer or using existing services if available) ---
3700
3725
  async fetchPatientProfile(patientId) {
3701
3726
  try {
3702
- const doc = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).get();
3703
- return doc.exists ? doc.data() : null;
3727
+ const doc2 = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).get();
3728
+ return doc2.exists ? doc2.data() : null;
3704
3729
  } catch (error) {
3705
3730
  Logger.error(`[AggService] Error fetching patient profile ${patientId}:`, error);
3706
3731
  return null;
@@ -3713,12 +3738,12 @@ var AppointmentAggregationService = class {
3713
3738
  */
3714
3739
  async fetchPatientSensitiveInfo(patientId) {
3715
3740
  try {
3716
- const doc = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).collection(PATIENT_SENSITIVE_INFO_COLLECTION).doc(patientId).get();
3717
- if (!doc.exists) {
3741
+ const doc2 = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).collection(PATIENT_SENSITIVE_INFO_COLLECTION).doc(patientId).get();
3742
+ if (!doc2.exists) {
3718
3743
  Logger.warn(`[AggService] No sensitive info found for patient ${patientId}`);
3719
3744
  return null;
3720
3745
  }
3721
- return doc.data();
3746
+ return doc2.data();
3722
3747
  } catch (error) {
3723
3748
  Logger.error(`[AggService] Error fetching patient sensitive info ${patientId}:`, error);
3724
3749
  return null;
@@ -3735,12 +3760,12 @@ var AppointmentAggregationService = class {
3735
3760
  return null;
3736
3761
  }
3737
3762
  try {
3738
- const doc = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
3739
- if (!doc.exists) {
3763
+ const doc2 = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
3764
+ if (!doc2.exists) {
3740
3765
  Logger.warn(`[AggService] No practitioner profile found for ID ${practitionerId}`);
3741
3766
  return null;
3742
3767
  }
3743
- return doc.data();
3768
+ return doc2.data();
3744
3769
  } catch (error) {
3745
3770
  Logger.error(`[AggService] Error fetching practitioner profile ${practitionerId}:`, error);
3746
3771
  return null;
@@ -3757,12 +3782,12 @@ var AppointmentAggregationService = class {
3757
3782
  return null;
3758
3783
  }
3759
3784
  try {
3760
- const doc = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
3761
- if (!doc.exists) {
3785
+ const doc2 = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
3786
+ if (!doc2.exists) {
3762
3787
  Logger.warn(`[AggService] No clinic info found for ID ${clinicId}`);
3763
3788
  return null;
3764
3789
  }
3765
- return doc.data();
3790
+ return doc2.data();
3766
3791
  } catch (error) {
3767
3792
  Logger.error(`[AggService] Error fetching clinic info ${clinicId}:`, error);
3768
3793
  return null;
@@ -3898,6 +3923,104 @@ var AppointmentAggregationService = class {
3898
3923
  );
3899
3924
  }
3900
3925
  }
3926
+ /**
3927
+ * Checks if recommended procedures have changed between two appointment states
3928
+ * @param before - The appointment state before update
3929
+ * @param after - The appointment state after update
3930
+ * @returns True if recommendations have changed, false otherwise
3931
+ */
3932
+ hasRecommendationsChanged(before, after) {
3933
+ var _a, _b;
3934
+ const beforeRecommendations = ((_a = before.metadata) == null ? void 0 : _a.recommendedProcedures) || [];
3935
+ const afterRecommendations = ((_b = after.metadata) == null ? void 0 : _b.recommendedProcedures) || [];
3936
+ if (beforeRecommendations.length !== afterRecommendations.length) {
3937
+ return true;
3938
+ }
3939
+ for (let i = 0; i < afterRecommendations.length; i++) {
3940
+ const beforeRec = beforeRecommendations[i];
3941
+ const afterRec = afterRecommendations[i];
3942
+ if (!beforeRec || !afterRec) {
3943
+ return true;
3944
+ }
3945
+ if (beforeRec.procedure.procedureId !== afterRec.procedure.procedureId || beforeRec.note !== afterRec.note || beforeRec.timeframe.value !== afterRec.timeframe.value || beforeRec.timeframe.unit !== afterRec.timeframe.unit) {
3946
+ return true;
3947
+ }
3948
+ }
3949
+ return false;
3950
+ }
3951
+ /**
3952
+ * Handles recommended procedures update - creates notifications for newly added recommendations
3953
+ * @param before - The appointment state before update
3954
+ * @param after - The appointment state after update
3955
+ * @param patientProfile - The patient profile (for expo tokens)
3956
+ */
3957
+ async handleRecommendedProceduresUpdate(before, after, patientProfile) {
3958
+ var _a, _b, _c, _d, _e;
3959
+ try {
3960
+ const beforeRecommendations = ((_a = before.metadata) == null ? void 0 : _a.recommendedProcedures) || [];
3961
+ const afterRecommendations = ((_b = after.metadata) == null ? void 0 : _b.recommendedProcedures) || [];
3962
+ const newRecommendations = afterRecommendations.slice(beforeRecommendations.length);
3963
+ if (newRecommendations.length === 0) {
3964
+ Logger.info(
3965
+ `[AggService] No new recommendations detected for appointment ${after.id}`
3966
+ );
3967
+ return;
3968
+ }
3969
+ Logger.info(
3970
+ `[AggService] Found ${newRecommendations.length} new recommendation(s) for appointment ${after.id}`
3971
+ );
3972
+ for (let i = 0; i < newRecommendations.length; i++) {
3973
+ const recommendation = newRecommendations[i];
3974
+ const recommendationIndex = beforeRecommendations.length + i;
3975
+ const recommendationId = `${after.id}:${recommendationIndex}`;
3976
+ const timeframeText = `${recommendation.timeframe.value} ${recommendation.timeframe.unit}${recommendation.timeframe.value > 1 ? "s" : ""}`;
3977
+ const notificationPayload = {
3978
+ userId: after.patientId,
3979
+ userRole: "patient" /* PATIENT */,
3980
+ notificationType: "procedureRecommendation" /* PROCEDURE_RECOMMENDATION */,
3981
+ notificationTime: admin6.firestore.Timestamp.now(),
3982
+ notificationTokens: (patientProfile == null ? void 0 : patientProfile.expoTokens) || [],
3983
+ title: "New Procedure Recommendation",
3984
+ body: `${((_c = after.practitionerInfo) == null ? void 0 : _c.name) || "Your doctor"} recommended "${recommendation.procedure.procedureName}" for you. Suggested timeframe: in ${timeframeText}`,
3985
+ appointmentId: after.id,
3986
+ recommendationId,
3987
+ procedureId: recommendation.procedure.procedureId,
3988
+ procedureName: recommendation.procedure.procedureName,
3989
+ practitionerName: ((_d = after.practitionerInfo) == null ? void 0 : _d.name) || "Unknown Practitioner",
3990
+ clinicName: ((_e = after.clinicInfo) == null ? void 0 : _e.name) || "Unknown Clinic",
3991
+ note: recommendation.note,
3992
+ timeframe: recommendation.timeframe
3993
+ };
3994
+ try {
3995
+ const notificationId = await this.notificationsAdmin.createNotification(
3996
+ notificationPayload
3997
+ );
3998
+ Logger.info(
3999
+ `[AggService] Created notification ${notificationId} for recommendation ${recommendationId}`
4000
+ );
4001
+ if ((patientProfile == null ? void 0 : patientProfile.expoTokens) && patientProfile.expoTokens.length > 0) {
4002
+ const notification = await this.notificationsAdmin.getNotification(notificationId);
4003
+ if (notification) {
4004
+ await this.notificationsAdmin.sendPushNotification(notification);
4005
+ Logger.info(
4006
+ `[AggService] Sent push notification for recommendation ${recommendationId}`
4007
+ );
4008
+ }
4009
+ }
4010
+ } catch (error) {
4011
+ Logger.error(
4012
+ `[AggService] Error creating notification for recommendation ${recommendationId}:`,
4013
+ error
4014
+ );
4015
+ }
4016
+ }
4017
+ } catch (error) {
4018
+ Logger.error(
4019
+ `[AggService] Error handling recommended procedures update for appointment ${after.id}:`,
4020
+ error
4021
+ );
4022
+ }
4023
+ }
3901
4024
  };
3902
4025
 
3903
4026
  // src/admin/aggregation/clinic/clinic.aggregation.service.ts
@@ -3908,8 +4031,8 @@ var ClinicAggregationService = class {
3908
4031
  * Constructor for ClinicAggregationService.
3909
4032
  * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
3910
4033
  */
3911
- constructor(firestore18) {
3912
- this.db = firestore18 || admin7.firestore();
4034
+ constructor(firestore19) {
4035
+ this.db = firestore19 || admin7.firestore();
3913
4036
  }
3914
4037
  /**
3915
4038
  * Adds clinic information to a clinic group when a new clinic is created
@@ -4131,11 +4254,11 @@ var ClinicAggregationService = class {
4131
4254
  return;
4132
4255
  }
4133
4256
  const batch = this.db.batch();
4134
- snapshot.docs.forEach((doc) => {
4257
+ snapshot.docs.forEach((doc2) => {
4135
4258
  console.log(
4136
- `[ClinicAggregationService] Updating location for calendar event ${doc.ref.path}`
4259
+ `[ClinicAggregationService] Updating location for calendar event ${doc2.ref.path}`
4137
4260
  );
4138
- batch.update(doc.ref, {
4261
+ batch.update(doc2.ref, {
4139
4262
  eventLocation: newLocation,
4140
4263
  updatedAt: admin7.firestore.FieldValue.serverTimestamp()
4141
4264
  });
@@ -4178,11 +4301,11 @@ var ClinicAggregationService = class {
4178
4301
  return;
4179
4302
  }
4180
4303
  const batch = this.db.batch();
4181
- snapshot.docs.forEach((doc) => {
4304
+ snapshot.docs.forEach((doc2) => {
4182
4305
  console.log(
4183
- `[ClinicAggregationService] Updating clinic info for calendar event ${doc.ref.path}`
4306
+ `[ClinicAggregationService] Updating clinic info for calendar event ${doc2.ref.path}`
4184
4307
  );
4185
- batch.update(doc.ref, {
4308
+ batch.update(doc2.ref, {
4186
4309
  clinicInfo,
4187
4310
  updatedAt: admin7.firestore.FieldValue.serverTimestamp()
4188
4311
  });
@@ -4393,11 +4516,11 @@ var ClinicAggregationService = class {
4393
4516
  return;
4394
4517
  }
4395
4518
  const batch = this.db.batch();
4396
- snapshot.docs.forEach((doc) => {
4519
+ snapshot.docs.forEach((doc2) => {
4397
4520
  console.log(
4398
- `[ClinicAggregationService] Canceling calendar event ${doc.ref.path}`
4521
+ `[ClinicAggregationService] Canceling calendar event ${doc2.ref.path}`
4399
4522
  );
4400
- batch.update(doc.ref, {
4523
+ batch.update(doc2.ref, {
4401
4524
  status: "CANCELED",
4402
4525
  cancelReason: "Clinic deleted",
4403
4526
  updatedAt: admin7.firestore.FieldValue.serverTimestamp()
@@ -4424,8 +4547,8 @@ var FilledFormsAggregationService = class {
4424
4547
  * Constructor for FilledFormsAggregationService.
4425
4548
  * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
4426
4549
  */
4427
- constructor(firestore18) {
4428
- this.db = firestore18 || admin8.firestore();
4550
+ constructor(firestore19) {
4551
+ this.db = firestore19 || admin8.firestore();
4429
4552
  Logger.info("[FilledFormsAggregationService] Initialized");
4430
4553
  }
4431
4554
  /**
@@ -4632,8 +4755,8 @@ var FilledFormsAggregationService = class {
4632
4755
  import * as admin9 from "firebase-admin";
4633
4756
  var CALENDAR_SUBCOLLECTION_ID2 = "calendar";
4634
4757
  var PatientAggregationService = class {
4635
- constructor(firestore18) {
4636
- this.db = firestore18 || admin9.firestore();
4758
+ constructor(firestore19) {
4759
+ this.db = firestore19 || admin9.firestore();
4637
4760
  }
4638
4761
  // --- Methods for Patient Creation --- >
4639
4762
  // No specific aggregations defined for patient creation in the plan.
@@ -4665,11 +4788,11 @@ var PatientAggregationService = class {
4665
4788
  return;
4666
4789
  }
4667
4790
  const batch = this.db.batch();
4668
- snapshot.docs.forEach((doc) => {
4791
+ snapshot.docs.forEach((doc2) => {
4669
4792
  console.log(
4670
- `[PatientAggregationService] Updating patient info for calendar event ${doc.ref.path}`
4793
+ `[PatientAggregationService] Updating patient info for calendar event ${doc2.ref.path}`
4671
4794
  );
4672
- batch.update(doc.ref, {
4795
+ batch.update(doc2.ref, {
4673
4796
  patientInfo,
4674
4797
  updatedAt: admin9.firestore.FieldValue.serverTimestamp()
4675
4798
  });
@@ -4713,11 +4836,11 @@ var PatientAggregationService = class {
4713
4836
  return;
4714
4837
  }
4715
4838
  const batch = this.db.batch();
4716
- snapshot.docs.forEach((doc) => {
4839
+ snapshot.docs.forEach((doc2) => {
4717
4840
  console.log(
4718
- `[PatientAggregationService] Canceling calendar event ${doc.ref.path}`
4841
+ `[PatientAggregationService] Canceling calendar event ${doc2.ref.path}`
4719
4842
  );
4720
- batch.update(doc.ref, {
4843
+ batch.update(doc2.ref, {
4721
4844
  status: "CANCELED",
4722
4845
  cancelReason: "Patient deleted",
4723
4846
  updatedAt: admin9.firestore.FieldValue.serverTimestamp()
@@ -4741,8 +4864,8 @@ var PatientAggregationService = class {
4741
4864
  import * as admin10 from "firebase-admin";
4742
4865
  var CALENDAR_SUBCOLLECTION_ID3 = "calendar";
4743
4866
  var PractitionerAggregationService = class {
4744
- constructor(firestore18) {
4745
- this.db = firestore18 || admin10.firestore();
4867
+ constructor(firestore19) {
4868
+ this.db = firestore19 || admin10.firestore();
4746
4869
  }
4747
4870
  /**
4748
4871
  * Adds practitioner information to a clinic when a new practitioner is created
@@ -4888,11 +5011,11 @@ var PractitionerAggregationService = class {
4888
5011
  return;
4889
5012
  }
4890
5013
  const batch = this.db.batch();
4891
- snapshot.docs.forEach((doc) => {
5014
+ snapshot.docs.forEach((doc2) => {
4892
5015
  console.log(
4893
- `[PractitionerAggregationService] Updating practitioner info for calendar event ${doc.ref.path}`
5016
+ `[PractitionerAggregationService] Updating practitioner info for calendar event ${doc2.ref.path}`
4894
5017
  );
4895
- batch.update(doc.ref, {
5018
+ batch.update(doc2.ref, {
4896
5019
  practitionerInfo,
4897
5020
  updatedAt: admin10.firestore.FieldValue.serverTimestamp()
4898
5021
  });
@@ -4976,11 +5099,11 @@ var PractitionerAggregationService = class {
4976
5099
  return;
4977
5100
  }
4978
5101
  const batch = this.db.batch();
4979
- snapshot.docs.forEach((doc) => {
5102
+ snapshot.docs.forEach((doc2) => {
4980
5103
  console.log(
4981
- `[PractitionerAggregationService] Canceling calendar event ${doc.ref.path}`
5104
+ `[PractitionerAggregationService] Canceling calendar event ${doc2.ref.path}`
4982
5105
  );
4983
- batch.update(doc.ref, {
5106
+ batch.update(doc2.ref, {
4984
5107
  status: "CANCELED",
4985
5108
  cancelReason: "Practitioner deleted",
4986
5109
  updatedAt: admin10.firestore.FieldValue.serverTimestamp()
@@ -5081,8 +5204,8 @@ var PractitionerInviteAggregationService = class {
5081
5204
  * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
5082
5205
  * @param mailingService Optional mailing service for sending emails
5083
5206
  */
5084
- constructor(firestore18, mailingService) {
5085
- this.db = firestore18 || admin11.firestore();
5207
+ constructor(firestore19, mailingService) {
5208
+ this.db = firestore19 || admin11.firestore();
5086
5209
  this.mailingService = mailingService;
5087
5210
  Logger.info("[PractitionerInviteAggregationService] Initialized.");
5088
5211
  }
@@ -5532,8 +5655,8 @@ var PractitionerInviteAggregationService = class {
5532
5655
  */
5533
5656
  async fetchClinicAdminById(adminId) {
5534
5657
  try {
5535
- const doc = await this.db.collection(CLINIC_ADMINS_COLLECTION).doc(adminId).get();
5536
- return doc.exists ? doc.data() : null;
5658
+ const doc2 = await this.db.collection(CLINIC_ADMINS_COLLECTION).doc(adminId).get();
5659
+ return doc2.exists ? doc2.data() : null;
5537
5660
  } catch (error) {
5538
5661
  Logger.error(
5539
5662
  `[PractitionerInviteAggService] Error fetching clinic admin ${adminId}:`,
@@ -5549,8 +5672,8 @@ var PractitionerInviteAggregationService = class {
5549
5672
  */
5550
5673
  async fetchPractitionerById(practitionerId) {
5551
5674
  try {
5552
- const doc = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
5553
- return doc.exists ? doc.data() : null;
5675
+ const doc2 = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
5676
+ return doc2.exists ? doc2.data() : null;
5554
5677
  } catch (error) {
5555
5678
  Logger.error(
5556
5679
  `[PractitionerInviteAggService] Error fetching practitioner ${practitionerId}:`,
@@ -5566,8 +5689,8 @@ var PractitionerInviteAggregationService = class {
5566
5689
  */
5567
5690
  async fetchClinicById(clinicId) {
5568
5691
  try {
5569
- const doc = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
5570
- return doc.exists ? doc.data() : null;
5692
+ const doc2 = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
5693
+ return doc2.exists ? doc2.data() : null;
5571
5694
  } catch (error) {
5572
5695
  Logger.error(
5573
5696
  `[PractitionerInviteAggService] Error fetching clinic ${clinicId}:`,
@@ -5588,8 +5711,8 @@ var PractitionerInviteAggregationService = class {
5588
5711
  var _a, _b, _c, _d, _e, _f;
5589
5712
  if (!this.mailingService) return;
5590
5713
  try {
5591
- const admin18 = await this.fetchClinicAdminById(invite.invitedBy);
5592
- if (!admin18) {
5714
+ const admin19 = await this.fetchClinicAdminById(invite.invitedBy);
5715
+ if (!admin19) {
5593
5716
  Logger.warn(
5594
5717
  `[PractitionerInviteAggService] Admin ${invite.invitedBy} not found, using clinic contact email as fallback`
5595
5718
  );
@@ -5627,7 +5750,7 @@ var PractitionerInviteAggregationService = class {
5627
5750
  );
5628
5751
  return;
5629
5752
  }
5630
- const adminName = `${admin18.contactInfo.firstName} ${admin18.contactInfo.lastName}`;
5753
+ const adminName = `${admin19.contactInfo.firstName} ${admin19.contactInfo.lastName}`;
5631
5754
  const notificationData = {
5632
5755
  invite,
5633
5756
  practitioner: {
@@ -5643,7 +5766,7 @@ var PractitionerInviteAggregationService = class {
5643
5766
  clinic: {
5644
5767
  name: clinic.name,
5645
5768
  adminName,
5646
- adminEmail: admin18.contactInfo.email
5769
+ adminEmail: admin19.contactInfo.email
5647
5770
  // Use the specific admin's email
5648
5771
  },
5649
5772
  context: {
@@ -5679,8 +5802,8 @@ var PractitionerInviteAggregationService = class {
5679
5802
  var _a, _b, _c, _d, _e, _f;
5680
5803
  if (!this.mailingService) return;
5681
5804
  try {
5682
- const admin18 = await this.fetchClinicAdminById(invite.invitedBy);
5683
- if (!admin18) {
5805
+ const admin19 = await this.fetchClinicAdminById(invite.invitedBy);
5806
+ if (!admin19) {
5684
5807
  Logger.warn(
5685
5808
  `[PractitionerInviteAggService] Admin ${invite.invitedBy} not found, using clinic contact email as fallback`
5686
5809
  );
@@ -5718,7 +5841,7 @@ var PractitionerInviteAggregationService = class {
5718
5841
  );
5719
5842
  return;
5720
5843
  }
5721
- const adminName = `${admin18.contactInfo.firstName} ${admin18.contactInfo.lastName}`;
5844
+ const adminName = `${admin19.contactInfo.firstName} ${admin19.contactInfo.lastName}`;
5722
5845
  const notificationData = {
5723
5846
  invite,
5724
5847
  practitioner: {
@@ -5732,7 +5855,7 @@ var PractitionerInviteAggregationService = class {
5732
5855
  clinic: {
5733
5856
  name: clinic.name,
5734
5857
  adminName,
5735
- adminEmail: admin18.contactInfo.email
5858
+ adminEmail: admin19.contactInfo.email
5736
5859
  // Use the specific admin's email
5737
5860
  },
5738
5861
  context: {
@@ -5764,8 +5887,8 @@ var PractitionerInviteAggregationService = class {
5764
5887
  import * as admin12 from "firebase-admin";
5765
5888
  var CALENDAR_SUBCOLLECTION_ID4 = "calendar";
5766
5889
  var ProcedureAggregationService = class {
5767
- constructor(firestore18) {
5768
- this.db = firestore18 || admin12.firestore();
5890
+ constructor(firestore19) {
5891
+ this.db = firestore19 || admin12.firestore();
5769
5892
  }
5770
5893
  /**
5771
5894
  * Adds procedure information to a practitioner when a new procedure is created
@@ -6002,11 +6125,11 @@ var ProcedureAggregationService = class {
6002
6125
  return;
6003
6126
  }
6004
6127
  const batch = this.db.batch();
6005
- snapshot.docs.forEach((doc) => {
6128
+ snapshot.docs.forEach((doc2) => {
6006
6129
  console.log(
6007
- `[ProcedureAggregationService] Updating procedure info for calendar event ${doc.ref.path}`
6130
+ `[ProcedureAggregationService] Updating procedure info for calendar event ${doc2.ref.path}`
6008
6131
  );
6009
- batch.update(doc.ref, {
6132
+ batch.update(doc2.ref, {
6010
6133
  procedureInfo,
6011
6134
  updatedAt: admin12.firestore.FieldValue.serverTimestamp()
6012
6135
  });
@@ -6049,11 +6172,11 @@ var ProcedureAggregationService = class {
6049
6172
  return;
6050
6173
  }
6051
6174
  const batch = this.db.batch();
6052
- snapshot.docs.forEach((doc) => {
6175
+ snapshot.docs.forEach((doc2) => {
6053
6176
  console.log(
6054
- `[ProcedureAggregationService] Canceling calendar event ${doc.ref.path}`
6177
+ `[ProcedureAggregationService] Canceling calendar event ${doc2.ref.path}`
6055
6178
  );
6056
- batch.update(doc.ref, {
6179
+ batch.update(doc2.ref, {
6057
6180
  status: "CANCELED",
6058
6181
  cancelReason: "Procedure deleted or inactivated",
6059
6182
  updatedAt: admin12.firestore.FieldValue.serverTimestamp()
@@ -6283,8 +6406,8 @@ var ReviewsAggregationService = class {
6283
6406
  * Constructor for ReviewsAggregationService.
6284
6407
  * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
6285
6408
  */
6286
- constructor(firestore18) {
6287
- this.db = firestore18 || admin13.firestore();
6409
+ constructor(firestore19) {
6410
+ this.db = firestore19 || admin13.firestore();
6288
6411
  }
6289
6412
  /**
6290
6413
  * Process a newly created review and update all related entities
@@ -6434,7 +6557,7 @@ var ReviewsAggregationService = class {
6434
6557
  );
6435
6558
  return updatedReviewInfo2;
6436
6559
  }
6437
- const reviews = reviewsQuery.docs.map((doc) => doc.data());
6560
+ const reviews = reviewsQuery.docs.map((doc2) => doc2.data());
6438
6561
  const clinicReviews = reviews.map((review) => review.clinicReview).filter((review) => review !== void 0);
6439
6562
  let totalRating = 0;
6440
6563
  let totalCleanliness = 0;
@@ -6524,7 +6647,7 @@ var ReviewsAggregationService = class {
6524
6647
  );
6525
6648
  return updatedReviewInfo2;
6526
6649
  }
6527
- const reviews = reviewsQuery.docs.map((doc) => doc.data());
6650
+ const reviews = reviewsQuery.docs.map((doc2) => doc2.data());
6528
6651
  const practitionerReviews = reviews.map((review) => review.practitionerReview).filter((review) => review !== void 0);
6529
6652
  let totalRating = 0;
6530
6653
  let totalKnowledgeAndExpertise = 0;
@@ -6597,7 +6720,7 @@ var ReviewsAggregationService = class {
6597
6720
  recommendationPercentage: 0
6598
6721
  };
6599
6722
  const allReviewsQuery = await this.db.collection(REVIEWS_COLLECTION).get();
6600
- const reviews = allReviewsQuery.docs.map((doc) => doc.data());
6723
+ const reviews = allReviewsQuery.docs.map((doc2) => doc2.data());
6601
6724
  const procedureReviews = [];
6602
6725
  reviews.forEach((review) => {
6603
6726
  if (review.procedureReview && review.procedureReview.procedureId === procedureId) {
@@ -6763,8 +6886,2042 @@ var ReviewsAggregationService = class {
6763
6886
  }
6764
6887
  };
6765
6888
 
6889
+ // src/admin/analytics/analytics.admin.service.ts
6890
+ import * as admin14 from "firebase-admin";
6891
+
6892
+ // src/services/analytics/analytics.service.ts
6893
+ import { where, Timestamp as Timestamp2 } from "firebase/firestore";
6894
+
6895
+ // src/services/base.service.ts
6896
+ import { getStorage } from "firebase/storage";
6897
+ var BaseService = class {
6898
+ constructor(db, auth, app, storage) {
6899
+ this.db = db;
6900
+ this.auth = auth;
6901
+ this.app = app;
6902
+ if (app) {
6903
+ this.storage = storage || getStorage(app);
6904
+ }
6905
+ }
6906
+ /**
6907
+ * Generiše jedinstveni ID za dokumente
6908
+ * Format: xxxxxxxxxxxx-timestamp
6909
+ * Gde je x random karakter (broj ili slovo)
6910
+ */
6911
+ generateId() {
6912
+ const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
6913
+ const timestamp = Date.now().toString(36);
6914
+ const randomPart = Array.from(
6915
+ { length: 12 },
6916
+ () => chars.charAt(Math.floor(Math.random() * chars.length))
6917
+ ).join("");
6918
+ return `${randomPart}-${timestamp}`;
6919
+ }
6920
+ };
6921
+
6922
+ // src/services/analytics/utils/cost-calculation.utils.ts
6923
+ function calculateAppointmentCost(appointment) {
6924
+ const metadata = appointment.metadata;
6925
+ const currency = appointment.currency || "CHF";
6926
+ if (metadata == null ? void 0 : metadata.finalbilling) {
6927
+ const finalbilling = metadata.finalbilling;
6928
+ return {
6929
+ cost: finalbilling.finalPrice,
6930
+ currency: finalbilling.currency || currency,
6931
+ source: "finalbilling",
6932
+ subtotal: finalbilling.subtotalAll,
6933
+ tax: finalbilling.taxPrice
6934
+ };
6935
+ }
6936
+ if (metadata == null ? void 0 : metadata.zonesData) {
6937
+ const zonesData = metadata.zonesData;
6938
+ let subtotal = 0;
6939
+ let foundCurrency = currency;
6940
+ Object.values(zonesData).forEach((items) => {
6941
+ items.forEach((item) => {
6942
+ if (item.type === "item" && item.subtotal) {
6943
+ subtotal += item.subtotal;
6944
+ if (item.currency && !foundCurrency) {
6945
+ foundCurrency = item.currency;
6946
+ }
6947
+ }
6948
+ });
6949
+ });
6950
+ if (subtotal > 0) {
6951
+ return {
6952
+ cost: subtotal,
6953
+ // Note: This doesn't include tax, but zonesData might not have tax info
6954
+ currency: foundCurrency,
6955
+ source: "zonesData",
6956
+ subtotal
6957
+ };
6958
+ }
6959
+ }
6960
+ return {
6961
+ cost: appointment.cost || 0,
6962
+ currency,
6963
+ source: "baseCost"
6964
+ };
6965
+ }
6966
+ function calculateTotalRevenue(appointments) {
6967
+ if (appointments.length === 0) {
6968
+ return { totalRevenue: 0, currency: "CHF" };
6969
+ }
6970
+ let totalRevenue = 0;
6971
+ const currencies = /* @__PURE__ */ new Set();
6972
+ appointments.forEach((appointment) => {
6973
+ const costData = calculateAppointmentCost(appointment);
6974
+ totalRevenue += costData.cost;
6975
+ currencies.add(costData.currency);
6976
+ });
6977
+ const currency = currencies.size > 0 ? Array.from(currencies)[0] : "CHF";
6978
+ return { totalRevenue, currency };
6979
+ }
6980
+ function extractProductUsage(appointment) {
6981
+ const products = [];
6982
+ const metadata = appointment.metadata;
6983
+ if (!(metadata == null ? void 0 : metadata.zonesData)) {
6984
+ return products;
6985
+ }
6986
+ const zonesData = metadata.zonesData;
6987
+ const currency = appointment.currency || "CHF";
6988
+ Object.values(zonesData).forEach((items) => {
6989
+ items.forEach((item) => {
6990
+ if (item.type === "item" && item.productId) {
6991
+ const price = item.priceOverrideAmount || item.price || 0;
6992
+ const quantity = item.quantity || 1;
6993
+ const subtotal = item.subtotal || price * quantity;
6994
+ products.push({
6995
+ productId: item.productId,
6996
+ productName: item.productName || "Unknown Product",
6997
+ brandId: item.productBrandId || "",
6998
+ brandName: item.productBrandName || "",
6999
+ quantity,
7000
+ price,
7001
+ subtotal,
7002
+ currency: item.currency || currency
7003
+ });
7004
+ }
7005
+ });
7006
+ });
7007
+ return products;
7008
+ }
7009
+
7010
+ // src/services/analytics/utils/time-calculation.utils.ts
7011
+ function calculateTimeEfficiency(appointment) {
7012
+ const startTime = appointment.appointmentStartTime;
7013
+ const endTime = appointment.appointmentEndTime;
7014
+ if (!startTime || !endTime) {
7015
+ return null;
7016
+ }
7017
+ const bookedDurationMs = endTime.toMillis() - startTime.toMillis();
7018
+ const bookedDuration = Math.round(bookedDurationMs / (1e3 * 60));
7019
+ const actualDuration = appointment.actualDurationMinutes || bookedDuration;
7020
+ const efficiency = bookedDuration > 0 ? actualDuration / bookedDuration * 100 : 100;
7021
+ const overrun = actualDuration > bookedDuration ? actualDuration - bookedDuration : 0;
7022
+ const underutilization = bookedDuration > actualDuration ? bookedDuration - actualDuration : 0;
7023
+ return {
7024
+ bookedDuration,
7025
+ actualDuration,
7026
+ efficiency,
7027
+ overrun,
7028
+ underutilization
7029
+ };
7030
+ }
7031
+ function calculateAverageTimeMetrics(appointments) {
7032
+ if (appointments.length === 0) {
7033
+ return {
7034
+ averageBookedDuration: 0,
7035
+ averageActualDuration: 0,
7036
+ averageEfficiency: 0,
7037
+ totalOverrun: 0,
7038
+ totalUnderutilization: 0,
7039
+ averageOverrun: 0,
7040
+ averageUnderutilization: 0,
7041
+ appointmentsWithActualTime: 0
7042
+ };
7043
+ }
7044
+ let totalBookedDuration = 0;
7045
+ let totalActualDuration = 0;
7046
+ let totalOverrun = 0;
7047
+ let totalUnderutilization = 0;
7048
+ let appointmentsWithActualTime = 0;
7049
+ appointments.forEach((appointment) => {
7050
+ const timeData = calculateTimeEfficiency(appointment);
7051
+ if (timeData) {
7052
+ totalBookedDuration += timeData.bookedDuration;
7053
+ totalActualDuration += timeData.actualDuration;
7054
+ totalOverrun += timeData.overrun;
7055
+ totalUnderutilization += timeData.underutilization;
7056
+ if (appointment.actualDurationMinutes !== void 0) {
7057
+ appointmentsWithActualTime++;
7058
+ }
7059
+ }
7060
+ });
7061
+ const count = appointments.length;
7062
+ const averageBookedDuration = count > 0 ? totalBookedDuration / count : 0;
7063
+ const averageActualDuration = count > 0 ? totalActualDuration / count : 0;
7064
+ const averageEfficiency = averageBookedDuration > 0 ? averageActualDuration / averageBookedDuration * 100 : 0;
7065
+ const averageOverrun = count > 0 ? totalOverrun / count : 0;
7066
+ const averageUnderutilization = count > 0 ? totalUnderutilization / count : 0;
7067
+ return {
7068
+ averageBookedDuration: Math.round(averageBookedDuration),
7069
+ averageActualDuration: Math.round(averageActualDuration),
7070
+ averageEfficiency: Math.round(averageEfficiency * 100) / 100,
7071
+ totalOverrun,
7072
+ totalUnderutilization,
7073
+ averageOverrun: Math.round(averageOverrun),
7074
+ averageUnderutilization: Math.round(averageUnderutilization),
7075
+ appointmentsWithActualTime
7076
+ };
7077
+ }
7078
+ function calculateEfficiencyDistribution(appointments) {
7079
+ const ranges = [
7080
+ { label: "0-50%", min: 0, max: 50 },
7081
+ { label: "50-75%", min: 50, max: 75 },
7082
+ { label: "75-100%", min: 75, max: 100 },
7083
+ { label: "100%+", min: 100, max: Infinity }
7084
+ ];
7085
+ const distribution = ranges.map((range) => ({
7086
+ range: range.label,
7087
+ count: 0,
7088
+ percentage: 0
7089
+ }));
7090
+ let validCount = 0;
7091
+ appointments.forEach((appointment) => {
7092
+ const timeData = calculateTimeEfficiency(appointment);
7093
+ if (timeData) {
7094
+ validCount++;
7095
+ const efficiency = timeData.efficiency;
7096
+ for (let i = 0; i < ranges.length; i++) {
7097
+ if (efficiency >= ranges[i].min && efficiency < ranges[i].max) {
7098
+ distribution[i].count++;
7099
+ break;
7100
+ }
7101
+ }
7102
+ }
7103
+ });
7104
+ if (validCount > 0) {
7105
+ distribution.forEach((item) => {
7106
+ item.percentage = Math.round(item.count / validCount * 100 * 100) / 100;
7107
+ });
7108
+ }
7109
+ return distribution;
7110
+ }
7111
+ function calculateCancellationLeadTime(appointment) {
7112
+ if (!appointment.cancellationTime || !appointment.appointmentStartTime) {
7113
+ return null;
7114
+ }
7115
+ const cancellationTime = appointment.cancellationTime.toMillis();
7116
+ const appointmentTime = appointment.appointmentStartTime.toMillis();
7117
+ const diffMs = appointmentTime - cancellationTime;
7118
+ return Math.max(0, diffMs / (1e3 * 60 * 60));
7119
+ }
7120
+
7121
+ // src/services/analytics/utils/appointment-filtering.utils.ts
7122
+ function filterAppointments(appointments, filters) {
7123
+ if (!filters) {
7124
+ return appointments;
7125
+ }
7126
+ return appointments.filter((appointment) => {
7127
+ if (filters.clinicBranchId && appointment.clinicBranchId !== filters.clinicBranchId) {
7128
+ return false;
7129
+ }
7130
+ if (filters.practitionerId && appointment.practitionerId !== filters.practitionerId) {
7131
+ return false;
7132
+ }
7133
+ if (filters.procedureId && appointment.procedureId !== filters.procedureId) {
7134
+ return false;
7135
+ }
7136
+ if (filters.patientId && appointment.patientId !== filters.patientId) {
7137
+ return false;
7138
+ }
7139
+ return true;
7140
+ });
7141
+ }
7142
+ function filterByStatus(appointments, statuses) {
7143
+ return appointments.filter((appointment) => statuses.includes(appointment.status));
7144
+ }
7145
+ function getCompletedAppointments(appointments) {
7146
+ return filterByStatus(appointments, ["completed" /* COMPLETED */]);
7147
+ }
7148
+ function getCanceledAppointments(appointments) {
7149
+ return filterByStatus(appointments, [
7150
+ "canceled_patient" /* CANCELED_PATIENT */,
7151
+ "canceled_clinic" /* CANCELED_CLINIC */,
7152
+ "canceled_patient_rescheduled" /* CANCELED_PATIENT_RESCHEDULED */
7153
+ ]);
7154
+ }
7155
+ function getNoShowAppointments(appointments) {
7156
+ return filterByStatus(appointments, ["no_show" /* NO_SHOW */]);
7157
+ }
7158
+ function calculatePercentage(part, total) {
7159
+ if (total === 0) {
7160
+ return 0;
7161
+ }
7162
+ return Math.round(part / total * 100 * 100) / 100;
7163
+ }
7164
+
7165
+ // src/services/analytics/utils/stored-analytics.utils.ts
7166
+ import { Timestamp, doc, getDoc } from "firebase/firestore";
7167
+ function isAnalyticsDataFresh(computedAt, maxAgeHours) {
7168
+ const now = Timestamp.now();
7169
+ const ageMs = now.toMillis() - computedAt.toMillis();
7170
+ const ageHours = ageMs / (1e3 * 60 * 60);
7171
+ return ageHours <= maxAgeHours;
7172
+ }
7173
+ async function readStoredAnalytics(db, clinicBranchId, subcollection, documentId, period) {
7174
+ try {
7175
+ const docRef = doc(
7176
+ db,
7177
+ CLINICS_COLLECTION,
7178
+ clinicBranchId,
7179
+ ANALYTICS_COLLECTION,
7180
+ subcollection,
7181
+ period,
7182
+ documentId
7183
+ );
7184
+ const docSnap = await getDoc(docRef);
7185
+ if (!docSnap.exists()) {
7186
+ return null;
7187
+ }
7188
+ return docSnap.data();
7189
+ } catch (error) {
7190
+ console.error(`[StoredAnalytics] Error reading ${subcollection}/${period}/${documentId}:`, error);
7191
+ return null;
7192
+ }
7193
+ }
7194
+ async function readStoredPractitionerAnalytics(db, clinicBranchId, practitionerId, options = {}) {
7195
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7196
+ if (!useCache) {
7197
+ return null;
7198
+ }
7199
+ const stored = await readStoredAnalytics(
7200
+ db,
7201
+ clinicBranchId,
7202
+ PRACTITIONER_ANALYTICS_SUBCOLLECTION,
7203
+ practitionerId,
7204
+ period
7205
+ );
7206
+ if (!stored) {
7207
+ return null;
7208
+ }
7209
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7210
+ return null;
7211
+ }
7212
+ return stored;
7213
+ }
7214
+ async function readStoredProcedureAnalytics(db, clinicBranchId, procedureId, options = {}) {
7215
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7216
+ if (!useCache) {
7217
+ return null;
7218
+ }
7219
+ const stored = await readStoredAnalytics(
7220
+ db,
7221
+ clinicBranchId,
7222
+ PROCEDURE_ANALYTICS_SUBCOLLECTION,
7223
+ procedureId,
7224
+ period
7225
+ );
7226
+ if (!stored) {
7227
+ return null;
7228
+ }
7229
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7230
+ return null;
7231
+ }
7232
+ return stored;
7233
+ }
7234
+ async function readStoredDashboardAnalytics(db, clinicBranchId, options = {}) {
7235
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7236
+ if (!useCache) {
7237
+ return null;
7238
+ }
7239
+ const stored = await readStoredAnalytics(
7240
+ db,
7241
+ clinicBranchId,
7242
+ DASHBOARD_ANALYTICS_SUBCOLLECTION,
7243
+ "current",
7244
+ period
7245
+ );
7246
+ if (!stored) {
7247
+ return null;
7248
+ }
7249
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7250
+ return null;
7251
+ }
7252
+ return stored;
7253
+ }
7254
+ async function readStoredTimeEfficiencyMetrics(db, clinicBranchId, options = {}) {
7255
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7256
+ if (!useCache) {
7257
+ return null;
7258
+ }
7259
+ const stored = await readStoredAnalytics(
7260
+ db,
7261
+ clinicBranchId,
7262
+ TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION,
7263
+ "current",
7264
+ period
7265
+ );
7266
+ if (!stored) {
7267
+ return null;
7268
+ }
7269
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7270
+ return null;
7271
+ }
7272
+ return stored;
7273
+ }
7274
+ async function readStoredRevenueMetrics(db, clinicBranchId, options = {}) {
7275
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7276
+ if (!useCache) {
7277
+ return null;
7278
+ }
7279
+ const stored = await readStoredAnalytics(
7280
+ db,
7281
+ clinicBranchId,
7282
+ REVENUE_ANALYTICS_SUBCOLLECTION,
7283
+ "current",
7284
+ period
7285
+ );
7286
+ if (!stored) {
7287
+ return null;
7288
+ }
7289
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7290
+ return null;
7291
+ }
7292
+ return stored;
7293
+ }
7294
+ async function readStoredCancellationMetrics(db, clinicBranchId, entityType, options = {}) {
7295
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7296
+ if (!useCache) {
7297
+ return null;
7298
+ }
7299
+ const stored = await readStoredAnalytics(
7300
+ db,
7301
+ clinicBranchId,
7302
+ CANCELLATION_ANALYTICS_SUBCOLLECTION,
7303
+ entityType,
7304
+ period
7305
+ );
7306
+ if (!stored) {
7307
+ return null;
7308
+ }
7309
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7310
+ return null;
7311
+ }
7312
+ return stored;
7313
+ }
7314
+ async function readStoredNoShowMetrics(db, clinicBranchId, entityType, options = {}) {
7315
+ const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
7316
+ if (!useCache) {
7317
+ return null;
7318
+ }
7319
+ const stored = await readStoredAnalytics(
7320
+ db,
7321
+ clinicBranchId,
7322
+ NO_SHOW_ANALYTICS_SUBCOLLECTION,
7323
+ entityType,
7324
+ period
7325
+ );
7326
+ if (!stored) {
7327
+ return null;
7328
+ }
7329
+ if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
7330
+ return null;
7331
+ }
7332
+ return stored;
7333
+ }
7334
+
7335
+ // src/services/analytics/utils/grouping.utils.ts
7336
+ function getTechnologyId(appointment) {
7337
+ var _a;
7338
+ return ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
7339
+ }
7340
+ function getTechnologyName(appointment) {
7341
+ var _a, _b;
7342
+ return ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyName) || ((_b = appointment.procedureInfo) == null ? void 0 : _b.technologyName) || "Unknown";
7343
+ }
7344
+ function getEntityName(appointment, entityType) {
7345
+ var _a, _b, _c, _d, _e, _f;
7346
+ switch (entityType) {
7347
+ case "clinic":
7348
+ return ((_a = appointment.clinicInfo) == null ? void 0 : _a.name) || "Unknown";
7349
+ case "practitioner":
7350
+ return ((_b = appointment.practitionerInfo) == null ? void 0 : _b.name) || "Unknown";
7351
+ case "patient":
7352
+ return ((_c = appointment.patientInfo) == null ? void 0 : _c.fullName) || "Unknown";
7353
+ case "procedure":
7354
+ return ((_d = appointment.procedureInfo) == null ? void 0 : _d.name) || "Unknown";
7355
+ case "technology":
7356
+ return ((_e = appointment.procedureExtendedInfo) == null ? void 0 : _e.procedureTechnologyName) || ((_f = appointment.procedureInfo) == null ? void 0 : _f.technologyName) || "Unknown";
7357
+ }
7358
+ }
7359
+ function getEntityId(appointment, entityType) {
7360
+ var _a;
7361
+ switch (entityType) {
7362
+ case "clinic":
7363
+ return appointment.clinicBranchId;
7364
+ case "practitioner":
7365
+ return appointment.practitionerId;
7366
+ case "patient":
7367
+ return appointment.patientId;
7368
+ case "procedure":
7369
+ return appointment.procedureId;
7370
+ case "technology":
7371
+ return ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
7372
+ }
7373
+ }
7374
+ function groupAppointmentsByEntity(appointments, entityType) {
7375
+ const entityMap = /* @__PURE__ */ new Map();
7376
+ appointments.forEach((appointment) => {
7377
+ let entityId;
7378
+ let entityName;
7379
+ if (entityType === "technology") {
7380
+ entityId = getTechnologyId(appointment);
7381
+ entityName = getTechnologyName(appointment);
7382
+ } else {
7383
+ entityId = getEntityId(appointment, entityType);
7384
+ entityName = getEntityName(appointment, entityType);
7385
+ }
7386
+ if (!entityMap.has(entityId)) {
7387
+ entityMap.set(entityId, { name: entityName, appointments: [] });
7388
+ }
7389
+ entityMap.get(entityId).appointments.push(appointment);
7390
+ });
7391
+ return entityMap;
7392
+ }
7393
+ function calculateGroupedRevenueMetrics(appointments, entityType) {
7394
+ const entityMap = groupAppointmentsByEntity(appointments, entityType);
7395
+ const completed = getCompletedAppointments(appointments);
7396
+ return Array.from(entityMap.entries()).map(([entityId, data]) => {
7397
+ const entityAppointments = data.appointments;
7398
+ const entityCompleted = entityAppointments.filter(
7399
+ (a) => completed.some((c) => c.id === a.id)
7400
+ );
7401
+ const { totalRevenue, currency } = calculateTotalRevenue(entityCompleted);
7402
+ let totalTax = 0;
7403
+ let totalSubtotal = 0;
7404
+ let unpaidRevenue = 0;
7405
+ let refundedRevenue = 0;
7406
+ entityCompleted.forEach((appointment) => {
7407
+ const costData = calculateAppointmentCost(appointment);
7408
+ if (costData.source === "finalbilling") {
7409
+ totalTax += costData.tax || 0;
7410
+ totalSubtotal += costData.subtotal || 0;
7411
+ } else {
7412
+ totalSubtotal += costData.cost;
7413
+ }
7414
+ if (appointment.paymentStatus === "unpaid") {
7415
+ unpaidRevenue += costData.cost;
7416
+ } else if (appointment.paymentStatus === "refunded") {
7417
+ refundedRevenue += costData.cost;
7418
+ }
7419
+ });
7420
+ return {
7421
+ entityId,
7422
+ entityName: data.name,
7423
+ entityType,
7424
+ totalRevenue,
7425
+ averageRevenuePerAppointment: entityCompleted.length > 0 ? totalRevenue / entityCompleted.length : 0,
7426
+ totalAppointments: entityAppointments.length,
7427
+ completedAppointments: entityCompleted.length,
7428
+ currency,
7429
+ unpaidRevenue,
7430
+ refundedRevenue,
7431
+ totalTax,
7432
+ totalSubtotal
7433
+ };
7434
+ });
7435
+ }
7436
+ function calculateGroupedProductUsageMetrics(appointments, entityType) {
7437
+ const entityMap = groupAppointmentsByEntity(appointments, entityType);
7438
+ const completed = getCompletedAppointments(appointments);
7439
+ return Array.from(entityMap.entries()).map(([entityId, data]) => {
7440
+ const entityAppointments = data.appointments;
7441
+ const entityCompleted = entityAppointments.filter(
7442
+ (a) => completed.some((c) => c.id === a.id)
7443
+ );
7444
+ const productMap = /* @__PURE__ */ new Map();
7445
+ entityCompleted.forEach((appointment) => {
7446
+ const products = extractProductUsage(appointment);
7447
+ products.forEach((product) => {
7448
+ if (productMap.has(product.productId)) {
7449
+ const existing = productMap.get(product.productId);
7450
+ existing.quantity += product.quantity;
7451
+ existing.revenue += product.subtotal;
7452
+ existing.usageCount++;
7453
+ } else {
7454
+ productMap.set(product.productId, {
7455
+ name: product.productName,
7456
+ brandName: product.brandName,
7457
+ quantity: product.quantity,
7458
+ revenue: product.subtotal,
7459
+ usageCount: 1
7460
+ });
7461
+ }
7462
+ });
7463
+ });
7464
+ const topProducts = Array.from(productMap.entries()).map(([productId, productData]) => ({
7465
+ productId,
7466
+ productName: productData.name,
7467
+ brandName: productData.brandName,
7468
+ totalQuantity: productData.quantity,
7469
+ totalRevenue: productData.revenue,
7470
+ usageCount: productData.usageCount
7471
+ })).sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, 10);
7472
+ const totalProductRevenue = topProducts.reduce((sum, p) => sum + p.totalRevenue, 0);
7473
+ const totalProductQuantity = topProducts.reduce((sum, p) => sum + p.totalQuantity, 0);
7474
+ return {
7475
+ entityId,
7476
+ entityName: data.name,
7477
+ entityType,
7478
+ totalProductsUsed: productMap.size,
7479
+ uniqueProducts: productMap.size,
7480
+ totalProductRevenue,
7481
+ totalProductQuantity,
7482
+ averageProductsPerAppointment: entityCompleted.length > 0 ? productMap.size / entityCompleted.length : 0,
7483
+ topProducts
7484
+ };
7485
+ });
7486
+ }
7487
+ function calculateGroupedTimeEfficiencyMetrics(appointments, entityType) {
7488
+ const entityMap = groupAppointmentsByEntity(appointments, entityType);
7489
+ const completed = getCompletedAppointments(appointments);
7490
+ return Array.from(entityMap.entries()).map(([entityId, data]) => {
7491
+ const entityAppointments = data.appointments;
7492
+ const entityCompleted = entityAppointments.filter(
7493
+ (a) => completed.some((c) => c.id === a.id)
7494
+ );
7495
+ const timeMetrics = calculateAverageTimeMetrics(entityCompleted);
7496
+ return {
7497
+ entityId,
7498
+ entityName: data.name,
7499
+ entityType,
7500
+ totalAppointments: entityCompleted.length,
7501
+ appointmentsWithActualTime: timeMetrics.appointmentsWithActualTime,
7502
+ averageBookedDuration: timeMetrics.averageBookedDuration,
7503
+ averageActualDuration: timeMetrics.averageActualDuration,
7504
+ averageEfficiency: timeMetrics.averageEfficiency,
7505
+ totalOverrun: timeMetrics.totalOverrun,
7506
+ totalUnderutilization: timeMetrics.totalUnderutilization,
7507
+ averageOverrun: timeMetrics.averageOverrun,
7508
+ averageUnderutilization: timeMetrics.averageUnderutilization
7509
+ };
7510
+ });
7511
+ }
7512
+ function calculateGroupedPatientBehaviorMetrics(appointments, entityType) {
7513
+ const entityMap = groupAppointmentsByEntity(appointments, entityType);
7514
+ const canceled = getCanceledAppointments(appointments);
7515
+ const noShow = getNoShowAppointments(appointments);
7516
+ return Array.from(entityMap.entries()).map(([entityId, data]) => {
7517
+ const entityAppointments = data.appointments;
7518
+ const patientMap = /* @__PURE__ */ new Map();
7519
+ entityAppointments.forEach((appointment) => {
7520
+ var _a;
7521
+ const patientId = appointment.patientId;
7522
+ const patientName = ((_a = appointment.patientInfo) == null ? void 0 : _a.fullName) || "Unknown";
7523
+ if (!patientMap.has(patientId)) {
7524
+ patientMap.set(patientId, {
7525
+ name: patientName,
7526
+ appointments: [],
7527
+ noShows: [],
7528
+ cancellations: []
7529
+ });
7530
+ }
7531
+ const patientData = patientMap.get(patientId);
7532
+ patientData.appointments.push(appointment);
7533
+ if (noShow.some((ns) => ns.id === appointment.id)) {
7534
+ patientData.noShows.push(appointment);
7535
+ }
7536
+ if (canceled.some((c) => c.id === appointment.id)) {
7537
+ patientData.cancellations.push(appointment);
7538
+ }
7539
+ });
7540
+ const patientMetrics = Array.from(patientMap.entries()).map(([patientId, patientData]) => ({
7541
+ patientId,
7542
+ patientName: patientData.name,
7543
+ noShowCount: patientData.noShows.length,
7544
+ cancellationCount: patientData.cancellations.length,
7545
+ totalAppointments: patientData.appointments.length,
7546
+ noShowRate: calculatePercentage(
7547
+ patientData.noShows.length,
7548
+ patientData.appointments.length
7549
+ ),
7550
+ cancellationRate: calculatePercentage(
7551
+ patientData.cancellations.length,
7552
+ patientData.appointments.length
7553
+ )
7554
+ }));
7555
+ const patientsWithNoShows = patientMetrics.filter((p) => p.noShowCount > 0).length;
7556
+ const patientsWithCancellations = patientMetrics.filter((p) => p.cancellationCount > 0).length;
7557
+ const averageNoShowRate = patientMetrics.length > 0 ? patientMetrics.reduce((sum, p) => sum + p.noShowRate, 0) / patientMetrics.length : 0;
7558
+ const averageCancellationRate = patientMetrics.length > 0 ? patientMetrics.reduce((sum, p) => sum + p.cancellationRate, 0) / patientMetrics.length : 0;
7559
+ const topNoShowPatients = patientMetrics.filter((p) => p.noShowCount > 0).sort((a, b) => b.noShowRate - a.noShowRate).slice(0, 10).map((p) => ({
7560
+ patientId: p.patientId,
7561
+ patientName: p.patientName,
7562
+ noShowCount: p.noShowCount,
7563
+ totalAppointments: p.totalAppointments,
7564
+ noShowRate: p.noShowRate
7565
+ }));
7566
+ const topCancellationPatients = patientMetrics.filter((p) => p.cancellationCount > 0).sort((a, b) => b.cancellationRate - a.cancellationRate).slice(0, 10).map((p) => ({
7567
+ patientId: p.patientId,
7568
+ patientName: p.patientName,
7569
+ cancellationCount: p.cancellationCount,
7570
+ totalAppointments: p.totalAppointments,
7571
+ cancellationRate: p.cancellationRate
7572
+ }));
7573
+ const newPatients = patientMetrics.filter((p) => p.totalAppointments === 1).length;
7574
+ const returningPatients = patientMetrics.filter((p) => p.totalAppointments > 1).length;
7575
+ return {
7576
+ entityId,
7577
+ entityName: data.name,
7578
+ entityType,
7579
+ totalPatients: patientMap.size,
7580
+ patientsWithNoShows,
7581
+ patientsWithCancellations,
7582
+ averageNoShowRate: Math.round(averageNoShowRate * 100) / 100,
7583
+ averageCancellationRate: Math.round(averageCancellationRate * 100) / 100,
7584
+ topNoShowPatients,
7585
+ topCancellationPatients
7586
+ };
7587
+ });
7588
+ }
7589
+
7590
+ // src/services/analytics/analytics.service.ts
7591
+ var AnalyticsService = class extends BaseService {
7592
+ /**
7593
+ * Creates a new AnalyticsService instance.
7594
+ *
7595
+ * @param db Firestore instance
7596
+ * @param auth Firebase Auth instance
7597
+ * @param app Firebase App instance
7598
+ * @param appointmentService Appointment service instance for querying appointments
7599
+ */
7600
+ constructor(db, auth, app, appointmentService) {
7601
+ super(db, auth, app);
7602
+ this.appointmentService = appointmentService;
7603
+ }
7604
+ /**
7605
+ * Fetches appointments with optional filters
7606
+ *
7607
+ * @param filters - Optional filters
7608
+ * @param dateRange - Optional date range
7609
+ * @returns Array of appointments
7610
+ */
7611
+ async fetchAppointments(filters, dateRange) {
7612
+ try {
7613
+ const constraints = [];
7614
+ if (filters == null ? void 0 : filters.clinicBranchId) {
7615
+ constraints.push(where("clinicBranchId", "==", filters.clinicBranchId));
7616
+ }
7617
+ if (filters == null ? void 0 : filters.practitionerId) {
7618
+ constraints.push(where("practitionerId", "==", filters.practitionerId));
7619
+ }
7620
+ if (filters == null ? void 0 : filters.procedureId) {
7621
+ constraints.push(where("procedureId", "==", filters.procedureId));
7622
+ }
7623
+ if (filters == null ? void 0 : filters.patientId) {
7624
+ constraints.push(where("patientId", "==", filters.patientId));
7625
+ }
7626
+ if (dateRange) {
7627
+ constraints.push(where("appointmentStartTime", ">=", Timestamp2.fromDate(dateRange.start)));
7628
+ constraints.push(where("appointmentStartTime", "<=", Timestamp2.fromDate(dateRange.end)));
7629
+ }
7630
+ const searchParams = {};
7631
+ if (filters == null ? void 0 : filters.clinicBranchId) searchParams.clinicBranchId = filters.clinicBranchId;
7632
+ if (filters == null ? void 0 : filters.practitionerId) searchParams.practitionerId = filters.practitionerId;
7633
+ if (filters == null ? void 0 : filters.procedureId) searchParams.procedureId = filters.procedureId;
7634
+ if (filters == null ? void 0 : filters.patientId) searchParams.patientId = filters.patientId;
7635
+ if (dateRange) {
7636
+ searchParams.startDate = dateRange.start;
7637
+ searchParams.endDate = dateRange.end;
7638
+ }
7639
+ const result = await this.appointmentService.searchAppointments(searchParams);
7640
+ return result.appointments;
7641
+ } catch (error) {
7642
+ console.error("[AnalyticsService] Error fetching appointments:", error);
7643
+ throw error;
7644
+ }
7645
+ }
7646
+ // ==========================================
7647
+ // Practitioner Analytics
7648
+ // ==========================================
7649
+ /**
7650
+ * Get practitioner performance metrics
7651
+ * First checks for stored analytics, then calculates if not available or stale
7652
+ *
7653
+ * @param practitionerId - ID of the practitioner
7654
+ * @param dateRange - Optional date range filter
7655
+ * @param options - Options for reading stored analytics
7656
+ * @returns Practitioner analytics object
7657
+ */
7658
+ async getPractitionerAnalytics(practitionerId, dateRange, options) {
7659
+ var _a;
7660
+ if (dateRange && (options == null ? void 0 : options.useCache) !== false) {
7661
+ const period = this.determinePeriodFromDateRange(dateRange);
7662
+ const clinicBranchId = options == null ? void 0 : options.clinicBranchId;
7663
+ if (clinicBranchId) {
7664
+ const stored = await readStoredPractitionerAnalytics(
7665
+ this.db,
7666
+ clinicBranchId,
7667
+ practitionerId,
7668
+ { ...options, period }
7669
+ );
7670
+ if (stored) {
7671
+ const { metadata, ...analytics } = stored;
7672
+ return analytics;
7673
+ }
7674
+ }
7675
+ }
7676
+ const appointments = await this.fetchAppointments({ practitionerId }, dateRange);
7677
+ const completed = getCompletedAppointments(appointments);
7678
+ const canceled = getCanceledAppointments(appointments);
7679
+ const noShow = getNoShowAppointments(appointments);
7680
+ const pending = filterAppointments(appointments, { practitionerId }).filter(
7681
+ (a) => a.status === "pending" /* PENDING */
7682
+ );
7683
+ const confirmed = filterAppointments(appointments, { practitionerId }).filter(
7684
+ (a) => a.status === "confirmed" /* CONFIRMED */
7685
+ );
7686
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
7687
+ const timeMetrics = calculateAverageTimeMetrics(completed);
7688
+ const uniquePatients = new Set(appointments.map((a) => a.patientId));
7689
+ const returningPatients = new Set(
7690
+ appointments.filter((a) => {
7691
+ const patientAppointments = appointments.filter((ap) => ap.patientId === a.patientId);
7692
+ return patientAppointments.length > 1;
7693
+ }).map((a) => a.patientId)
7694
+ );
7695
+ const procedureMap = /* @__PURE__ */ new Map();
7696
+ completed.forEach((appointment) => {
7697
+ var _a2;
7698
+ const procId = appointment.procedureId;
7699
+ const procName = ((_a2 = appointment.procedureInfo) == null ? void 0 : _a2.name) || "Unknown";
7700
+ const cost = calculateAppointmentCost(appointment).cost;
7701
+ if (procedureMap.has(procId)) {
7702
+ const existing = procedureMap.get(procId);
7703
+ existing.count++;
7704
+ existing.revenue += cost;
7705
+ } else {
7706
+ procedureMap.set(procId, { name: procName, count: 1, revenue: cost });
7707
+ }
7708
+ });
7709
+ const topProcedures = Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
7710
+ procedureId,
7711
+ procedureName: data.name,
7712
+ count: data.count,
7713
+ revenue: data.revenue
7714
+ })).sort((a, b) => b.count - a.count).slice(0, 10);
7715
+ const practitionerName = appointments.length > 0 ? ((_a = appointments[0].practitionerInfo) == null ? void 0 : _a.name) || "Unknown" : "Unknown";
7716
+ return {
7717
+ total: appointments.length,
7718
+ dateRange,
7719
+ practitionerId,
7720
+ practitionerName,
7721
+ totalAppointments: appointments.length,
7722
+ completedAppointments: completed.length,
7723
+ canceledAppointments: canceled.length,
7724
+ noShowAppointments: noShow.length,
7725
+ pendingAppointments: pending.length,
7726
+ confirmedAppointments: confirmed.length,
7727
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
7728
+ noShowRate: calculatePercentage(noShow.length, appointments.length),
7729
+ averageBookedTime: timeMetrics.averageBookedDuration,
7730
+ averageActualTime: timeMetrics.averageActualDuration,
7731
+ timeEfficiency: timeMetrics.averageEfficiency,
7732
+ totalRevenue,
7733
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
7734
+ currency,
7735
+ topProcedures,
7736
+ patientRetentionRate: calculatePercentage(returningPatients.size, uniquePatients.size),
7737
+ uniquePatients: uniquePatients.size
7738
+ };
7739
+ }
7740
+ // ==========================================
7741
+ // Procedure Analytics
7742
+ // ==========================================
7743
+ /**
7744
+ * Get procedure performance metrics
7745
+ * First checks for stored analytics, then calculates if not available or stale
7746
+ *
7747
+ * @param procedureId - ID of the procedure (optional, if not provided returns all)
7748
+ * @param dateRange - Optional date range filter
7749
+ * @param options - Options for reading stored analytics
7750
+ * @returns Procedure analytics object or array
7751
+ */
7752
+ async getProcedureAnalytics(procedureId, dateRange, options) {
7753
+ if (procedureId && dateRange && (options == null ? void 0 : options.useCache) !== false) {
7754
+ const period = this.determinePeriodFromDateRange(dateRange);
7755
+ const clinicBranchId = options == null ? void 0 : options.clinicBranchId;
7756
+ if (clinicBranchId) {
7757
+ const stored = await readStoredProcedureAnalytics(
7758
+ this.db,
7759
+ clinicBranchId,
7760
+ procedureId,
7761
+ { ...options, period }
7762
+ );
7763
+ if (stored) {
7764
+ const { metadata, ...analytics } = stored;
7765
+ return analytics;
7766
+ }
7767
+ }
7768
+ }
7769
+ const appointments = await this.fetchAppointments(procedureId ? { procedureId } : void 0, dateRange);
7770
+ if (procedureId) {
7771
+ return this.calculateProcedureAnalytics(appointments, procedureId);
7772
+ }
7773
+ const procedureMap = /* @__PURE__ */ new Map();
7774
+ appointments.forEach((appointment) => {
7775
+ const procId = appointment.procedureId;
7776
+ if (!procedureMap.has(procId)) {
7777
+ procedureMap.set(procId, []);
7778
+ }
7779
+ procedureMap.get(procId).push(appointment);
7780
+ });
7781
+ return Array.from(procedureMap.entries()).map(
7782
+ ([procId, procAppointments]) => this.calculateProcedureAnalytics(procAppointments, procId)
7783
+ );
7784
+ }
7785
+ /**
7786
+ * Calculate analytics for a specific procedure
7787
+ *
7788
+ * @param appointments - Appointments for the procedure
7789
+ * @param procedureId - Procedure ID
7790
+ * @returns Procedure analytics
7791
+ */
7792
+ calculateProcedureAnalytics(appointments, procedureId) {
7793
+ const completed = getCompletedAppointments(appointments);
7794
+ const canceled = getCanceledAppointments(appointments);
7795
+ const noShow = getNoShowAppointments(appointments);
7796
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
7797
+ const timeMetrics = calculateAverageTimeMetrics(completed);
7798
+ const firstAppointment = appointments[0];
7799
+ const procedureInfo = (firstAppointment == null ? void 0 : firstAppointment.procedureExtendedInfo) || (firstAppointment == null ? void 0 : firstAppointment.procedureInfo);
7800
+ const productMap = /* @__PURE__ */ new Map();
7801
+ completed.forEach((appointment) => {
7802
+ const products = extractProductUsage(appointment);
7803
+ products.forEach((product) => {
7804
+ if (productMap.has(product.productId)) {
7805
+ const existing = productMap.get(product.productId);
7806
+ existing.quantity += product.quantity;
7807
+ existing.revenue += product.subtotal;
7808
+ existing.usageCount++;
7809
+ } else {
7810
+ productMap.set(product.productId, {
7811
+ name: product.productName,
7812
+ brandName: product.brandName,
7813
+ quantity: product.quantity,
7814
+ revenue: product.subtotal,
7815
+ usageCount: 1
7816
+ });
7817
+ }
7818
+ });
7819
+ });
7820
+ const productUsage = Array.from(productMap.entries()).map(([productId, data]) => ({
7821
+ productId,
7822
+ productName: data.name,
7823
+ brandName: data.brandName,
7824
+ totalQuantity: data.quantity,
7825
+ totalRevenue: data.revenue,
7826
+ usageCount: data.usageCount
7827
+ }));
7828
+ return {
7829
+ total: appointments.length,
7830
+ procedureId,
7831
+ procedureName: (procedureInfo == null ? void 0 : procedureInfo.name) || "Unknown",
7832
+ procedureFamily: (procedureInfo == null ? void 0 : procedureInfo.procedureFamily) || "",
7833
+ categoryName: (procedureInfo == null ? void 0 : procedureInfo.procedureCategoryName) || "",
7834
+ subcategoryName: (procedureInfo == null ? void 0 : procedureInfo.procedureSubCategoryName) || "",
7835
+ technologyName: (procedureInfo == null ? void 0 : procedureInfo.procedureTechnologyName) || "",
7836
+ totalAppointments: appointments.length,
7837
+ completedAppointments: completed.length,
7838
+ canceledAppointments: canceled.length,
7839
+ noShowAppointments: noShow.length,
7840
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
7841
+ noShowRate: calculatePercentage(noShow.length, appointments.length),
7842
+ averageCost: completed.length > 0 ? totalRevenue / completed.length : 0,
7843
+ totalRevenue,
7844
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
7845
+ currency,
7846
+ averageBookedDuration: timeMetrics.averageBookedDuration,
7847
+ averageActualDuration: timeMetrics.averageActualDuration,
7848
+ productUsage
7849
+ };
7850
+ }
7851
+ /**
7852
+ * Get procedure popularity metrics
7853
+ *
7854
+ * @param dateRange - Optional date range filter
7855
+ * @param limit - Number of top procedures to return
7856
+ * @returns Array of procedure popularity metrics
7857
+ */
7858
+ async getProcedurePopularity(dateRange, limit = 10) {
7859
+ const appointments = await this.fetchAppointments(void 0, dateRange);
7860
+ const completed = getCompletedAppointments(appointments);
7861
+ const procedureMap = /* @__PURE__ */ new Map();
7862
+ completed.forEach((appointment) => {
7863
+ const procId = appointment.procedureId;
7864
+ const procInfo = appointment.procedureExtendedInfo || appointment.procedureInfo;
7865
+ if (procedureMap.has(procId)) {
7866
+ procedureMap.get(procId).count++;
7867
+ } else {
7868
+ procedureMap.set(procId, {
7869
+ name: (procInfo == null ? void 0 : procInfo.name) || "Unknown",
7870
+ category: (procInfo == null ? void 0 : procInfo.procedureCategoryName) || "",
7871
+ subcategory: (procInfo == null ? void 0 : procInfo.procedureSubCategoryName) || "",
7872
+ technology: (procInfo == null ? void 0 : procInfo.procedureTechnologyName) || "",
7873
+ count: 1
7874
+ });
7875
+ }
7876
+ });
7877
+ return Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
7878
+ procedureId,
7879
+ procedureName: data.name,
7880
+ categoryName: data.category,
7881
+ subcategoryName: data.subcategory,
7882
+ technologyName: data.technology,
7883
+ appointmentCount: data.count,
7884
+ completedCount: data.count,
7885
+ rank: 0
7886
+ // Will be set after sorting
7887
+ })).sort((a, b) => b.appointmentCount - a.appointmentCount).slice(0, limit).map((item, index) => ({ ...item, rank: index + 1 }));
7888
+ }
7889
+ /**
7890
+ * Get procedure profitability metrics
7891
+ *
7892
+ * @param dateRange - Optional date range filter
7893
+ * @param limit - Number of top procedures to return
7894
+ * @returns Array of procedure profitability metrics
7895
+ */
7896
+ async getProcedureProfitability(dateRange, limit = 10) {
7897
+ const appointments = await this.fetchAppointments(void 0, dateRange);
7898
+ const completed = getCompletedAppointments(appointments);
7899
+ const procedureMap = /* @__PURE__ */ new Map();
7900
+ completed.forEach((appointment) => {
7901
+ const procId = appointment.procedureId;
7902
+ const procInfo = appointment.procedureExtendedInfo || appointment.procedureInfo;
7903
+ const cost = calculateAppointmentCost(appointment).cost;
7904
+ if (procedureMap.has(procId)) {
7905
+ const existing = procedureMap.get(procId);
7906
+ existing.revenue += cost;
7907
+ existing.count++;
7908
+ } else {
7909
+ procedureMap.set(procId, {
7910
+ name: (procInfo == null ? void 0 : procInfo.name) || "Unknown",
7911
+ category: (procInfo == null ? void 0 : procInfo.procedureCategoryName) || "",
7912
+ subcategory: (procInfo == null ? void 0 : procInfo.procedureSubCategoryName) || "",
7913
+ technology: (procInfo == null ? void 0 : procInfo.procedureTechnologyName) || "",
7914
+ revenue: cost,
7915
+ count: 1
7916
+ });
7917
+ }
7918
+ });
7919
+ return Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
7920
+ procedureId,
7921
+ procedureName: data.name,
7922
+ categoryName: data.category,
7923
+ subcategoryName: data.subcategory,
7924
+ technologyName: data.technology,
7925
+ totalRevenue: data.revenue,
7926
+ averageRevenue: data.count > 0 ? data.revenue / data.count : 0,
7927
+ appointmentCount: data.count,
7928
+ rank: 0
7929
+ // Will be set after sorting
7930
+ })).sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, limit).map((item, index) => ({ ...item, rank: index + 1 }));
7931
+ }
7932
+ // ==========================================
7933
+ // Time Efficiency Analytics
7934
+ // ==========================================
7935
+ /**
7936
+ * Get time efficiency metrics grouped by clinic, practitioner, procedure, patient, or technology
7937
+ *
7938
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient' | 'technology'
7939
+ * @param dateRange - Optional date range filter
7940
+ * @param filters - Optional additional filters
7941
+ * @returns Grouped time efficiency metrics
7942
+ */
7943
+ async getTimeEfficiencyMetricsByEntity(groupBy, dateRange, filters) {
7944
+ const appointments = await this.fetchAppointments(filters, dateRange);
7945
+ return calculateGroupedTimeEfficiencyMetrics(appointments, groupBy);
7946
+ }
7947
+ /**
7948
+ * Get time efficiency metrics for appointments
7949
+ * First checks for stored analytics, then calculates if not available or stale
7950
+ *
7951
+ * @param filters - Optional filters
7952
+ * @param dateRange - Optional date range filter
7953
+ * @param options - Options for reading stored analytics
7954
+ * @returns Time efficiency metrics
7955
+ */
7956
+ async getTimeEfficiencyMetrics(filters, dateRange, options) {
7957
+ if ((filters == null ? void 0 : filters.clinicBranchId) && dateRange && (options == null ? void 0 : options.useCache) !== false) {
7958
+ const period = this.determinePeriodFromDateRange(dateRange);
7959
+ const stored = await readStoredTimeEfficiencyMetrics(
7960
+ this.db,
7961
+ filters.clinicBranchId,
7962
+ { ...options, period }
7963
+ );
7964
+ if (stored) {
7965
+ const { metadata, ...metrics } = stored;
7966
+ return metrics;
7967
+ }
7968
+ }
7969
+ const appointments = await this.fetchAppointments(filters, dateRange);
7970
+ const completed = getCompletedAppointments(appointments);
7971
+ const timeMetrics = calculateAverageTimeMetrics(completed);
7972
+ const efficiencyDistribution = calculateEfficiencyDistribution(completed);
7973
+ return {
7974
+ totalAppointments: completed.length,
7975
+ appointmentsWithActualTime: timeMetrics.appointmentsWithActualTime,
7976
+ averageBookedDuration: timeMetrics.averageBookedDuration,
7977
+ averageActualDuration: timeMetrics.averageActualDuration,
7978
+ averageEfficiency: timeMetrics.averageEfficiency,
7979
+ totalOverrun: timeMetrics.totalOverrun,
7980
+ totalUnderutilization: timeMetrics.totalUnderutilization,
7981
+ averageOverrun: timeMetrics.averageOverrun,
7982
+ averageUnderutilization: timeMetrics.averageUnderutilization,
7983
+ efficiencyDistribution
7984
+ };
7985
+ }
7986
+ // ==========================================
7987
+ // Cancellation & No-Show Analytics
7988
+ // ==========================================
7989
+ /**
7990
+ * Get cancellation metrics
7991
+ * First checks for stored analytics when grouping by clinic, then calculates if not available or stale
7992
+ *
7993
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'patient' | 'procedure' | 'technology'
7994
+ * @param dateRange - Optional date range filter
7995
+ * @param options - Options for reading stored analytics (requires clinicBranchId for cache)
7996
+ * @returns Cancellation metrics grouped by specified entity
7997
+ */
7998
+ async getCancellationMetrics(groupBy, dateRange, options) {
7999
+ if (groupBy === "clinic" && dateRange && (options == null ? void 0 : options.useCache) !== false && (options == null ? void 0 : options.clinicBranchId)) {
8000
+ const period = this.determinePeriodFromDateRange(dateRange);
8001
+ const stored = await readStoredCancellationMetrics(
8002
+ this.db,
8003
+ options.clinicBranchId,
8004
+ "clinic",
8005
+ { ...options, period }
8006
+ );
8007
+ if (stored) {
8008
+ const { metadata, ...metrics } = stored;
8009
+ return metrics;
8010
+ }
8011
+ }
8012
+ const appointments = await this.fetchAppointments(void 0, dateRange);
8013
+ const canceled = getCanceledAppointments(appointments);
8014
+ if (groupBy === "clinic") {
8015
+ return this.groupCancellationsByClinic(canceled, appointments);
8016
+ } else if (groupBy === "practitioner") {
8017
+ return this.groupCancellationsByPractitioner(canceled, appointments);
8018
+ } else if (groupBy === "patient") {
8019
+ return this.groupCancellationsByPatient(canceled, appointments);
8020
+ } else if (groupBy === "technology") {
8021
+ return this.groupCancellationsByTechnology(canceled, appointments);
8022
+ } else {
8023
+ return this.groupCancellationsByProcedure(canceled, appointments);
8024
+ }
8025
+ }
8026
+ /**
8027
+ * Group cancellations by clinic
8028
+ */
8029
+ groupCancellationsByClinic(canceled, allAppointments) {
8030
+ const clinicMap = /* @__PURE__ */ new Map();
8031
+ allAppointments.forEach((appointment) => {
8032
+ var _a;
8033
+ const clinicId = appointment.clinicBranchId;
8034
+ const clinicName = ((_a = appointment.clinicInfo) == null ? void 0 : _a.name) || "Unknown";
8035
+ if (!clinicMap.has(clinicId)) {
8036
+ clinicMap.set(clinicId, { name: clinicName, canceled: [], all: [] });
8037
+ }
8038
+ clinicMap.get(clinicId).all.push(appointment);
8039
+ });
8040
+ canceled.forEach((appointment) => {
8041
+ const clinicId = appointment.clinicBranchId;
8042
+ if (clinicMap.has(clinicId)) {
8043
+ clinicMap.get(clinicId).canceled.push(appointment);
8044
+ }
8045
+ });
8046
+ return Array.from(clinicMap.entries()).map(
8047
+ ([clinicId, data]) => this.calculateCancellationMetrics(clinicId, data.name, "clinic", data.canceled, data.all)
8048
+ );
8049
+ }
8050
+ /**
8051
+ * Group cancellations by practitioner
8052
+ */
8053
+ groupCancellationsByPractitioner(canceled, allAppointments) {
8054
+ const practitionerMap = /* @__PURE__ */ new Map();
8055
+ allAppointments.forEach((appointment) => {
8056
+ var _a;
8057
+ const practitionerId = appointment.practitionerId;
8058
+ const practitionerName = ((_a = appointment.practitionerInfo) == null ? void 0 : _a.name) || "Unknown";
8059
+ if (!practitionerMap.has(practitionerId)) {
8060
+ practitionerMap.set(practitionerId, { name: practitionerName, canceled: [], all: [] });
8061
+ }
8062
+ practitionerMap.get(practitionerId).all.push(appointment);
8063
+ });
8064
+ canceled.forEach((appointment) => {
8065
+ const practitionerId = appointment.practitionerId;
8066
+ if (practitionerMap.has(practitionerId)) {
8067
+ practitionerMap.get(practitionerId).canceled.push(appointment);
8068
+ }
8069
+ });
8070
+ return Array.from(practitionerMap.entries()).map(
8071
+ ([practitionerId, data]) => this.calculateCancellationMetrics(
8072
+ practitionerId,
8073
+ data.name,
8074
+ "practitioner",
8075
+ data.canceled,
8076
+ data.all
8077
+ )
8078
+ );
8079
+ }
8080
+ /**
8081
+ * Group cancellations by patient
8082
+ */
8083
+ groupCancellationsByPatient(canceled, allAppointments) {
8084
+ const patientMap = /* @__PURE__ */ new Map();
8085
+ allAppointments.forEach((appointment) => {
8086
+ var _a;
8087
+ const patientId = appointment.patientId;
8088
+ const patientName = ((_a = appointment.patientInfo) == null ? void 0 : _a.fullName) || "Unknown";
8089
+ if (!patientMap.has(patientId)) {
8090
+ patientMap.set(patientId, { name: patientName, canceled: [], all: [] });
8091
+ }
8092
+ patientMap.get(patientId).all.push(appointment);
8093
+ });
8094
+ canceled.forEach((appointment) => {
8095
+ const patientId = appointment.patientId;
8096
+ if (patientMap.has(patientId)) {
8097
+ patientMap.get(patientId).canceled.push(appointment);
8098
+ }
8099
+ });
8100
+ return Array.from(patientMap.entries()).map(
8101
+ ([patientId, data]) => this.calculateCancellationMetrics(patientId, data.name, "patient", data.canceled, data.all)
8102
+ );
8103
+ }
8104
+ /**
8105
+ * Group cancellations by procedure
8106
+ */
8107
+ groupCancellationsByProcedure(canceled, allAppointments) {
8108
+ const procedureMap = /* @__PURE__ */ new Map();
8109
+ allAppointments.forEach((appointment) => {
8110
+ var _a;
8111
+ const procedureId = appointment.procedureId;
8112
+ const procedureName = ((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown";
8113
+ if (!procedureMap.has(procedureId)) {
8114
+ procedureMap.set(procedureId, { name: procedureName, canceled: [], all: [] });
8115
+ }
8116
+ procedureMap.get(procedureId).all.push(appointment);
8117
+ });
8118
+ canceled.forEach((appointment) => {
8119
+ const procedureId = appointment.procedureId;
8120
+ if (procedureMap.has(procedureId)) {
8121
+ procedureMap.get(procedureId).canceled.push(appointment);
8122
+ }
8123
+ });
8124
+ return Array.from(procedureMap.entries()).map(
8125
+ ([procedureId, data]) => this.calculateCancellationMetrics(
8126
+ procedureId,
8127
+ data.name,
8128
+ "procedure",
8129
+ data.canceled,
8130
+ data.all
8131
+ )
8132
+ );
8133
+ }
8134
+ /**
8135
+ * Group cancellations by technology
8136
+ * Aggregates all procedures using the same technology across all doctors
8137
+ */
8138
+ groupCancellationsByTechnology(canceled, allAppointments) {
8139
+ const technologyMap = /* @__PURE__ */ new Map();
8140
+ allAppointments.forEach((appointment) => {
8141
+ var _a, _b, _c;
8142
+ const technologyId = ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
8143
+ const technologyName = ((_b = appointment.procedureExtendedInfo) == null ? void 0 : _b.procedureTechnologyName) || ((_c = appointment.procedureInfo) == null ? void 0 : _c.technologyName) || "Unknown";
8144
+ if (!technologyMap.has(technologyId)) {
8145
+ technologyMap.set(technologyId, { name: technologyName, canceled: [], all: [] });
8146
+ }
8147
+ technologyMap.get(technologyId).all.push(appointment);
8148
+ });
8149
+ canceled.forEach((appointment) => {
8150
+ var _a;
8151
+ const technologyId = ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
8152
+ if (technologyMap.has(technologyId)) {
8153
+ technologyMap.get(technologyId).canceled.push(appointment);
8154
+ }
8155
+ });
8156
+ return Array.from(technologyMap.entries()).map(
8157
+ ([technologyId, data]) => this.calculateCancellationMetrics(
8158
+ technologyId,
8159
+ data.name,
8160
+ "technology",
8161
+ data.canceled,
8162
+ data.all
8163
+ )
8164
+ );
8165
+ }
8166
+ /**
8167
+ * Calculate cancellation metrics for a specific entity
8168
+ */
8169
+ calculateCancellationMetrics(entityId, entityName, entityType, canceled, all) {
8170
+ const canceledByPatient = canceled.filter(
8171
+ (a) => a.status === "canceled_patient" /* CANCELED_PATIENT */
8172
+ ).length;
8173
+ const canceledByClinic = canceled.filter(
8174
+ (a) => a.status === "canceled_clinic" /* CANCELED_CLINIC */
8175
+ ).length;
8176
+ const canceledRescheduled = canceled.filter(
8177
+ (a) => a.status === "canceled_patient_rescheduled" /* CANCELED_PATIENT_RESCHEDULED */
8178
+ ).length;
8179
+ const leadTimes = canceled.map((a) => calculateCancellationLeadTime(a)).filter((lt) => lt !== null);
8180
+ const averageLeadTime = leadTimes.length > 0 ? leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length : 0;
8181
+ const reasonMap = /* @__PURE__ */ new Map();
8182
+ canceled.forEach((appointment) => {
8183
+ const reason = appointment.cancellationReason || "No reason provided";
8184
+ reasonMap.set(reason, (reasonMap.get(reason) || 0) + 1);
8185
+ });
8186
+ const cancellationReasons = Array.from(reasonMap.entries()).map(([reason, count]) => ({
8187
+ reason,
8188
+ count,
8189
+ percentage: calculatePercentage(count, canceled.length)
8190
+ }));
8191
+ return {
8192
+ entityId,
8193
+ entityName,
8194
+ entityType,
8195
+ totalAppointments: all.length,
8196
+ canceledAppointments: canceled.length,
8197
+ cancellationRate: calculatePercentage(canceled.length, all.length),
8198
+ canceledByPatient,
8199
+ canceledByClinic,
8200
+ canceledByPractitioner: 0,
8201
+ // Not tracked in current status enum
8202
+ canceledRescheduled,
8203
+ averageCancellationLeadTime: Math.round(averageLeadTime * 100) / 100,
8204
+ cancellationReasons
8205
+ };
8206
+ }
8207
+ /**
8208
+ * Get no-show metrics
8209
+ * First checks for stored analytics when grouping by clinic, then calculates if not available or stale
8210
+ *
8211
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'patient' | 'procedure' | 'technology'
8212
+ * @param dateRange - Optional date range filter
8213
+ * @param options - Options for reading stored analytics (requires clinicBranchId for cache)
8214
+ * @returns No-show metrics grouped by specified entity
8215
+ */
8216
+ async getNoShowMetrics(groupBy, dateRange, options) {
8217
+ if (groupBy === "clinic" && dateRange && (options == null ? void 0 : options.useCache) !== false && (options == null ? void 0 : options.clinicBranchId)) {
8218
+ const period = this.determinePeriodFromDateRange(dateRange);
8219
+ const stored = await readStoredNoShowMetrics(
8220
+ this.db,
8221
+ options.clinicBranchId,
8222
+ "clinic",
8223
+ { ...options, period }
8224
+ );
8225
+ if (stored) {
8226
+ const { metadata, ...metrics } = stored;
8227
+ return metrics;
8228
+ }
8229
+ }
8230
+ const appointments = await this.fetchAppointments(void 0, dateRange);
8231
+ const noShow = getNoShowAppointments(appointments);
8232
+ if (groupBy === "clinic") {
8233
+ return this.groupNoShowsByClinic(noShow, appointments);
8234
+ } else if (groupBy === "practitioner") {
8235
+ return this.groupNoShowsByPractitioner(noShow, appointments);
8236
+ } else if (groupBy === "patient") {
8237
+ return this.groupNoShowsByPatient(noShow, appointments);
8238
+ } else if (groupBy === "technology") {
8239
+ return this.groupNoShowsByTechnology(noShow, appointments);
8240
+ } else {
8241
+ return this.groupNoShowsByProcedure(noShow, appointments);
8242
+ }
8243
+ }
8244
+ /**
8245
+ * Group no-shows by clinic
8246
+ */
8247
+ groupNoShowsByClinic(noShow, allAppointments) {
8248
+ const clinicMap = /* @__PURE__ */ new Map();
8249
+ allAppointments.forEach((appointment) => {
8250
+ var _a;
8251
+ const clinicId = appointment.clinicBranchId;
8252
+ const clinicName = ((_a = appointment.clinicInfo) == null ? void 0 : _a.name) || "Unknown";
8253
+ if (!clinicMap.has(clinicId)) {
8254
+ clinicMap.set(clinicId, { name: clinicName, noShow: [], all: [] });
8255
+ }
8256
+ clinicMap.get(clinicId).all.push(appointment);
8257
+ });
8258
+ noShow.forEach((appointment) => {
8259
+ const clinicId = appointment.clinicBranchId;
8260
+ if (clinicMap.has(clinicId)) {
8261
+ clinicMap.get(clinicId).noShow.push(appointment);
8262
+ }
8263
+ });
8264
+ return Array.from(clinicMap.entries()).map(([clinicId, data]) => ({
8265
+ entityId: clinicId,
8266
+ entityName: data.name,
8267
+ entityType: "clinic",
8268
+ totalAppointments: data.all.length,
8269
+ noShowAppointments: data.noShow.length,
8270
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length)
8271
+ }));
8272
+ }
8273
+ /**
8274
+ * Group no-shows by practitioner
8275
+ */
8276
+ groupNoShowsByPractitioner(noShow, allAppointments) {
8277
+ const practitionerMap = /* @__PURE__ */ new Map();
8278
+ allAppointments.forEach((appointment) => {
8279
+ var _a;
8280
+ const practitionerId = appointment.practitionerId;
8281
+ const practitionerName = ((_a = appointment.practitionerInfo) == null ? void 0 : _a.name) || "Unknown";
8282
+ if (!practitionerMap.has(practitionerId)) {
8283
+ practitionerMap.set(practitionerId, { name: practitionerName, noShow: [], all: [] });
8284
+ }
8285
+ practitionerMap.get(practitionerId).all.push(appointment);
8286
+ });
8287
+ noShow.forEach((appointment) => {
8288
+ const practitionerId = appointment.practitionerId;
8289
+ if (practitionerMap.has(practitionerId)) {
8290
+ practitionerMap.get(practitionerId).noShow.push(appointment);
8291
+ }
8292
+ });
8293
+ return Array.from(practitionerMap.entries()).map(([practitionerId, data]) => ({
8294
+ entityId: practitionerId,
8295
+ entityName: data.name,
8296
+ entityType: "practitioner",
8297
+ totalAppointments: data.all.length,
8298
+ noShowAppointments: data.noShow.length,
8299
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length)
8300
+ }));
8301
+ }
8302
+ /**
8303
+ * Group no-shows by patient
8304
+ */
8305
+ groupNoShowsByPatient(noShow, allAppointments) {
8306
+ const patientMap = /* @__PURE__ */ new Map();
8307
+ allAppointments.forEach((appointment) => {
8308
+ var _a;
8309
+ const patientId = appointment.patientId;
8310
+ const patientName = ((_a = appointment.patientInfo) == null ? void 0 : _a.fullName) || "Unknown";
8311
+ if (!patientMap.has(patientId)) {
8312
+ patientMap.set(patientId, { name: patientName, noShow: [], all: [] });
8313
+ }
8314
+ patientMap.get(patientId).all.push(appointment);
8315
+ });
8316
+ noShow.forEach((appointment) => {
8317
+ const patientId = appointment.patientId;
8318
+ if (patientMap.has(patientId)) {
8319
+ patientMap.get(patientId).noShow.push(appointment);
8320
+ }
8321
+ });
8322
+ return Array.from(patientMap.entries()).map(([patientId, data]) => ({
8323
+ entityId: patientId,
8324
+ entityName: data.name,
8325
+ entityType: "patient",
8326
+ totalAppointments: data.all.length,
8327
+ noShowAppointments: data.noShow.length,
8328
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length)
8329
+ }));
8330
+ }
8331
+ /**
8332
+ * Group no-shows by procedure
8333
+ */
8334
+ groupNoShowsByProcedure(noShow, allAppointments) {
8335
+ const procedureMap = /* @__PURE__ */ new Map();
8336
+ allAppointments.forEach((appointment) => {
8337
+ var _a;
8338
+ const procedureId = appointment.procedureId;
8339
+ const procedureName = ((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown";
8340
+ if (!procedureMap.has(procedureId)) {
8341
+ procedureMap.set(procedureId, { name: procedureName, noShow: [], all: [] });
8342
+ }
8343
+ procedureMap.get(procedureId).all.push(appointment);
8344
+ });
8345
+ noShow.forEach((appointment) => {
8346
+ const procedureId = appointment.procedureId;
8347
+ if (procedureMap.has(procedureId)) {
8348
+ procedureMap.get(procedureId).noShow.push(appointment);
8349
+ }
8350
+ });
8351
+ return Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
8352
+ entityId: procedureId,
8353
+ entityName: data.name,
8354
+ entityType: "procedure",
8355
+ totalAppointments: data.all.length,
8356
+ noShowAppointments: data.noShow.length,
8357
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length)
8358
+ }));
8359
+ }
8360
+ /**
8361
+ * Group no-shows by technology
8362
+ * Aggregates all procedures using the same technology across all doctors
8363
+ */
8364
+ groupNoShowsByTechnology(noShow, allAppointments) {
8365
+ const technologyMap = /* @__PURE__ */ new Map();
8366
+ allAppointments.forEach((appointment) => {
8367
+ var _a, _b, _c;
8368
+ const technologyId = ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
8369
+ const technologyName = ((_b = appointment.procedureExtendedInfo) == null ? void 0 : _b.procedureTechnologyName) || ((_c = appointment.procedureInfo) == null ? void 0 : _c.technologyName) || "Unknown";
8370
+ if (!technologyMap.has(technologyId)) {
8371
+ technologyMap.set(technologyId, { name: technologyName, noShow: [], all: [] });
8372
+ }
8373
+ technologyMap.get(technologyId).all.push(appointment);
8374
+ });
8375
+ noShow.forEach((appointment) => {
8376
+ var _a;
8377
+ const technologyId = ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
8378
+ if (technologyMap.has(technologyId)) {
8379
+ technologyMap.get(technologyId).noShow.push(appointment);
8380
+ }
8381
+ });
8382
+ return Array.from(technologyMap.entries()).map(([technologyId, data]) => ({
8383
+ entityId: technologyId,
8384
+ entityName: data.name,
8385
+ entityType: "technology",
8386
+ totalAppointments: data.all.length,
8387
+ noShowAppointments: data.noShow.length,
8388
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length)
8389
+ }));
8390
+ }
8391
+ // ==========================================
8392
+ // Financial Analytics
8393
+ // ==========================================
8394
+ /**
8395
+ * Get revenue metrics grouped by clinic, practitioner, procedure, patient, or technology
8396
+ *
8397
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient' | 'technology'
8398
+ * @param dateRange - Optional date range filter
8399
+ * @param filters - Optional additional filters
8400
+ * @returns Grouped revenue metrics
8401
+ */
8402
+ async getRevenueMetricsByEntity(groupBy, dateRange, filters) {
8403
+ const appointments = await this.fetchAppointments(filters, dateRange);
8404
+ return calculateGroupedRevenueMetrics(appointments, groupBy);
8405
+ }
8406
+ /**
8407
+ * Get revenue metrics
8408
+ * First checks for stored analytics, then calculates if not available or stale
8409
+ *
8410
+ * @param filters - Optional filters
8411
+ * @param dateRange - Optional date range filter
8412
+ * @param options - Options for reading stored analytics
8413
+ * @returns Revenue metrics
8414
+ */
8415
+ async getRevenueMetrics(filters, dateRange, options) {
8416
+ if ((filters == null ? void 0 : filters.clinicBranchId) && dateRange && (options == null ? void 0 : options.useCache) !== false) {
8417
+ const period = this.determinePeriodFromDateRange(dateRange);
8418
+ const stored = await readStoredRevenueMetrics(
8419
+ this.db,
8420
+ filters.clinicBranchId,
8421
+ { ...options, period }
8422
+ );
8423
+ if (stored) {
8424
+ const { metadata, ...metrics } = stored;
8425
+ return metrics;
8426
+ }
8427
+ }
8428
+ const appointments = await this.fetchAppointments(filters, dateRange);
8429
+ const completed = getCompletedAppointments(appointments);
8430
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
8431
+ const revenueByStatus = {};
8432
+ Object.values(AppointmentStatus).forEach((status) => {
8433
+ const statusAppointments = appointments.filter((a) => a.status === status);
8434
+ const { totalRevenue: statusRevenue } = calculateTotalRevenue(statusAppointments);
8435
+ revenueByStatus[status] = statusRevenue;
8436
+ });
8437
+ const revenueByPaymentStatus = {};
8438
+ Object.values(PaymentStatus).forEach((paymentStatus) => {
8439
+ const paymentAppointments = completed.filter((a) => a.paymentStatus === paymentStatus);
8440
+ const { totalRevenue: paymentRevenue } = calculateTotalRevenue(paymentAppointments);
8441
+ revenueByPaymentStatus[paymentStatus] = paymentRevenue;
8442
+ });
8443
+ const unpaid = completed.filter((a) => a.paymentStatus === "unpaid" /* UNPAID */);
8444
+ const refunded = completed.filter((a) => a.paymentStatus === "refunded" /* REFUNDED */);
8445
+ const { totalRevenue: unpaidRevenue } = calculateTotalRevenue(unpaid);
8446
+ const { totalRevenue: refundedRevenue } = calculateTotalRevenue(refunded);
8447
+ let totalTax = 0;
8448
+ let totalSubtotal = 0;
8449
+ completed.forEach((appointment) => {
8450
+ const costData = calculateAppointmentCost(appointment);
8451
+ if (costData.source === "finalbilling") {
8452
+ totalTax += costData.tax || 0;
8453
+ totalSubtotal += costData.subtotal || 0;
8454
+ } else {
8455
+ totalSubtotal += costData.cost;
8456
+ }
8457
+ });
8458
+ return {
8459
+ totalRevenue,
8460
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
8461
+ totalAppointments: appointments.length,
8462
+ completedAppointments: completed.length,
8463
+ currency,
8464
+ revenueByStatus,
8465
+ revenueByPaymentStatus,
8466
+ unpaidRevenue,
8467
+ refundedRevenue,
8468
+ totalTax,
8469
+ totalSubtotal
8470
+ };
8471
+ }
8472
+ // ==========================================
8473
+ // Product Usage Analytics
8474
+ // ==========================================
8475
+ /**
8476
+ * Get product usage metrics grouped by clinic, practitioner, procedure, or patient
8477
+ *
8478
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient'
8479
+ * @param dateRange - Optional date range filter
8480
+ * @param filters - Optional additional filters
8481
+ * @returns Grouped product usage metrics
8482
+ */
8483
+ async getProductUsageMetricsByEntity(groupBy, dateRange, filters) {
8484
+ const appointments = await this.fetchAppointments(filters, dateRange);
8485
+ return calculateGroupedProductUsageMetrics(appointments, groupBy);
8486
+ }
8487
+ /**
8488
+ * Get product usage metrics
8489
+ *
8490
+ * @param productId - Optional product ID (if not provided, returns all products)
8491
+ * @param dateRange - Optional date range filter
8492
+ * @returns Product usage metrics
8493
+ */
8494
+ async getProductUsageMetrics(productId, dateRange) {
8495
+ const appointments = await this.fetchAppointments(void 0, dateRange);
8496
+ const completed = getCompletedAppointments(appointments);
8497
+ const productMap = /* @__PURE__ */ new Map();
8498
+ completed.forEach((appointment) => {
8499
+ const products = extractProductUsage(appointment);
8500
+ products.forEach((product) => {
8501
+ var _a;
8502
+ if (productId && product.productId !== productId) {
8503
+ return;
8504
+ }
8505
+ if (!productMap.has(product.productId)) {
8506
+ productMap.set(product.productId, {
8507
+ name: product.productName,
8508
+ brandId: product.brandId,
8509
+ brandName: product.brandName,
8510
+ quantity: 0,
8511
+ revenue: 0,
8512
+ usageCount: 0,
8513
+ procedureMap: /* @__PURE__ */ new Map()
8514
+ });
8515
+ }
8516
+ const productData = productMap.get(product.productId);
8517
+ productData.quantity += product.quantity;
8518
+ productData.revenue += product.subtotal;
8519
+ productData.usageCount++;
8520
+ const procId = appointment.procedureId;
8521
+ const procName = ((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown";
8522
+ if (productData.procedureMap.has(procId)) {
8523
+ const procData = productData.procedureMap.get(procId);
8524
+ procData.count++;
8525
+ procData.quantity += product.quantity;
8526
+ } else {
8527
+ productData.procedureMap.set(procId, {
8528
+ name: procName,
8529
+ count: 1,
8530
+ quantity: product.quantity
8531
+ });
8532
+ }
8533
+ });
8534
+ });
8535
+ const results = Array.from(productMap.entries()).map(([productId2, data]) => ({
8536
+ productId: productId2,
8537
+ productName: data.name,
8538
+ brandId: data.brandId,
8539
+ brandName: data.brandName,
8540
+ totalQuantity: data.quantity,
8541
+ totalRevenue: data.revenue,
8542
+ averagePrice: data.usageCount > 0 ? data.revenue / data.quantity : 0,
8543
+ currency: "CHF",
8544
+ // Could be extracted from products
8545
+ usageCount: data.usageCount,
8546
+ averageQuantityPerAppointment: data.usageCount > 0 ? data.quantity / data.usageCount : 0,
8547
+ usageByProcedure: Array.from(data.procedureMap.entries()).map(([procId, procData]) => ({
8548
+ procedureId: procId,
8549
+ procedureName: procData.name,
8550
+ count: procData.count,
8551
+ totalQuantity: procData.quantity
8552
+ }))
8553
+ }));
8554
+ return productId ? results[0] : results;
8555
+ }
8556
+ // ==========================================
8557
+ // Patient Analytics
8558
+ // ==========================================
8559
+ /**
8560
+ * Get patient behavior metrics grouped by clinic, practitioner, procedure, or technology
8561
+ * Shows patient no-show and cancellation patterns per entity
8562
+ *
8563
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'technology'
8564
+ * @param dateRange - Optional date range filter
8565
+ * @param filters - Optional additional filters
8566
+ * @returns Grouped patient behavior metrics
8567
+ */
8568
+ async getPatientBehaviorMetricsByEntity(groupBy, dateRange, filters) {
8569
+ const appointments = await this.fetchAppointments(filters, dateRange);
8570
+ return calculateGroupedPatientBehaviorMetrics(appointments, groupBy);
8571
+ }
8572
+ /**
8573
+ * Get patient analytics
8574
+ *
8575
+ * @param patientId - Optional patient ID (if not provided, returns aggregate)
8576
+ * @param dateRange - Optional date range filter
8577
+ * @returns Patient analytics
8578
+ */
8579
+ async getPatientAnalytics(patientId, dateRange) {
8580
+ const appointments = await this.fetchAppointments(patientId ? { patientId } : void 0, dateRange);
8581
+ if (patientId) {
8582
+ return this.calculatePatientAnalytics(appointments, patientId);
8583
+ }
8584
+ const patientMap = /* @__PURE__ */ new Map();
8585
+ appointments.forEach((appointment) => {
8586
+ const patId = appointment.patientId;
8587
+ if (!patientMap.has(patId)) {
8588
+ patientMap.set(patId, []);
8589
+ }
8590
+ patientMap.get(patId).push(appointment);
8591
+ });
8592
+ return Array.from(patientMap.entries()).map(
8593
+ ([patId, patAppointments]) => this.calculatePatientAnalytics(patAppointments, patId)
8594
+ );
8595
+ }
8596
+ /**
8597
+ * Calculate analytics for a specific patient
8598
+ */
8599
+ calculatePatientAnalytics(appointments, patientId) {
8600
+ var _a;
8601
+ const completed = getCompletedAppointments(appointments);
8602
+ const canceled = getCanceledAppointments(appointments);
8603
+ const noShow = getNoShowAppointments(appointments);
8604
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
8605
+ const appointmentDates = appointments.map((a) => a.appointmentStartTime.toDate()).sort((a, b) => a.getTime() - b.getTime());
8606
+ const firstAppointmentDate = appointmentDates.length > 0 ? appointmentDates[0] : null;
8607
+ const lastAppointmentDate = appointmentDates.length > 0 ? appointmentDates[appointmentDates.length - 1] : null;
8608
+ let averageDaysBetween = null;
8609
+ if (appointmentDates.length > 1) {
8610
+ const intervals = [];
8611
+ for (let i = 1; i < appointmentDates.length; i++) {
8612
+ const diffMs = appointmentDates[i].getTime() - appointmentDates[i - 1].getTime();
8613
+ intervals.push(diffMs / (1e3 * 60 * 60 * 24));
8614
+ }
8615
+ averageDaysBetween = intervals.reduce((a, b) => a + b, 0) / intervals.length;
8616
+ }
8617
+ const uniquePractitioners = new Set(appointments.map((a) => a.practitionerId));
8618
+ const uniqueClinics = new Set(appointments.map((a) => a.clinicBranchId));
8619
+ const procedureMap = /* @__PURE__ */ new Map();
8620
+ completed.forEach((appointment) => {
8621
+ var _a2, _b;
8622
+ const procId = appointment.procedureId;
8623
+ const procName = ((_a2 = appointment.procedureInfo) == null ? void 0 : _a2.name) || "Unknown";
8624
+ procedureMap.set(procId, {
8625
+ name: procName,
8626
+ count: (((_b = procedureMap.get(procId)) == null ? void 0 : _b.count) || 0) + 1
8627
+ });
8628
+ });
8629
+ const favoriteProcedures = Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
8630
+ procedureId,
8631
+ procedureName: data.name,
8632
+ count: data.count
8633
+ })).sort((a, b) => b.count - a.count).slice(0, 5);
8634
+ const patientName = appointments.length > 0 ? ((_a = appointments[0].patientInfo) == null ? void 0 : _a.fullName) || "Unknown" : "Unknown";
8635
+ return {
8636
+ patientId,
8637
+ patientName,
8638
+ totalAppointments: appointments.length,
8639
+ completedAppointments: completed.length,
8640
+ canceledAppointments: canceled.length,
8641
+ noShowAppointments: noShow.length,
8642
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
8643
+ noShowRate: calculatePercentage(noShow.length, appointments.length),
8644
+ totalRevenue,
8645
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
8646
+ currency,
8647
+ lifetimeValue: totalRevenue,
8648
+ firstAppointmentDate,
8649
+ lastAppointmentDate,
8650
+ averageDaysBetweenAppointments: averageDaysBetween ? Math.round(averageDaysBetween) : null,
8651
+ uniquePractitioners: uniquePractitioners.size,
8652
+ uniqueClinics: uniqueClinics.size,
8653
+ favoriteProcedures
8654
+ };
8655
+ }
8656
+ // ==========================================
8657
+ // Dashboard Analytics
8658
+ // ==========================================
8659
+ /**
8660
+ * Determines analytics period from date range
8661
+ */
8662
+ determinePeriodFromDateRange(dateRange) {
8663
+ const diffMs = dateRange.end.getTime() - dateRange.start.getTime();
8664
+ const diffDays = diffMs / (1e3 * 60 * 60 * 24);
8665
+ if (diffDays <= 1) return "daily";
8666
+ if (diffDays <= 7) return "weekly";
8667
+ if (diffDays <= 31) return "monthly";
8668
+ if (diffDays <= 365) return "yearly";
8669
+ return "all_time";
8670
+ }
8671
+ /**
8672
+ * Get comprehensive dashboard data
8673
+ * First checks for stored analytics, then calculates if not available or stale
8674
+ *
8675
+ * @param filters - Optional filters
8676
+ * @param dateRange - Optional date range filter
8677
+ * @param options - Options for reading stored analytics
8678
+ * @returns Complete dashboard analytics
8679
+ */
8680
+ async getDashboardData(filters, dateRange, options) {
8681
+ if ((filters == null ? void 0 : filters.clinicBranchId) && dateRange && (options == null ? void 0 : options.useCache) !== false) {
8682
+ const period = this.determinePeriodFromDateRange(dateRange);
8683
+ const stored = await readStoredDashboardAnalytics(
8684
+ this.db,
8685
+ filters.clinicBranchId,
8686
+ { ...options, period }
8687
+ );
8688
+ if (stored) {
8689
+ const { metadata, ...analytics } = stored;
8690
+ return analytics;
8691
+ }
8692
+ }
8693
+ const appointments = await this.fetchAppointments(filters, dateRange);
8694
+ const completed = getCompletedAppointments(appointments);
8695
+ const canceled = getCanceledAppointments(appointments);
8696
+ const noShow = getNoShowAppointments(appointments);
8697
+ const pending = appointments.filter((a) => a.status === "pending" /* PENDING */);
8698
+ const confirmed = appointments.filter((a) => a.status === "confirmed" /* CONFIRMED */);
8699
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
8700
+ const uniquePatients = new Set(appointments.map((a) => a.patientId));
8701
+ const uniquePractitioners = new Set(appointments.map((a) => a.practitionerId));
8702
+ const uniqueProcedures = new Set(appointments.map((a) => a.procedureId));
8703
+ const practitionerMetrics = await Promise.all(
8704
+ Array.from(uniquePractitioners).slice(0, 5).map((practitionerId) => this.getPractitionerAnalytics(practitionerId, dateRange))
8705
+ );
8706
+ const procedureMetricsResults = await Promise.all(
8707
+ Array.from(uniqueProcedures).slice(0, 5).map((procedureId) => this.getProcedureAnalytics(procedureId, dateRange))
8708
+ );
8709
+ const procedureMetrics = procedureMetricsResults.filter(
8710
+ (result) => !Array.isArray(result)
8711
+ );
8712
+ const cancellationMetrics = await this.getCancellationMetrics("clinic", dateRange);
8713
+ const noShowMetrics = await this.getNoShowMetrics("clinic", dateRange);
8714
+ const timeEfficiency = await this.getTimeEfficiencyMetrics(filters, dateRange);
8715
+ const productMetrics = await this.getProductUsageMetrics(void 0, dateRange);
8716
+ const topProducts = Array.isArray(productMetrics) ? productMetrics.sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, 5) : [];
8717
+ const recentActivity = appointments.sort((a, b) => b.appointmentStartTime.toMillis() - a.appointmentStartTime.toMillis()).slice(0, 10).map((appointment) => {
8718
+ var _a, _b, _c, _d, _e;
8719
+ let type = "appointment";
8720
+ let description = "";
8721
+ if (appointment.status === "completed" /* COMPLETED */) {
8722
+ type = "completion";
8723
+ description = `Appointment completed: ${((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown procedure"}`;
8724
+ } else if (appointment.status === "canceled_patient" /* CANCELED_PATIENT */ || appointment.status === "canceled_clinic" /* CANCELED_CLINIC */ || appointment.status === "canceled_patient_rescheduled" /* CANCELED_PATIENT_RESCHEDULED */) {
8725
+ type = "cancellation";
8726
+ description = `Appointment canceled: ${((_b = appointment.procedureInfo) == null ? void 0 : _b.name) || "Unknown procedure"}`;
8727
+ } else if (appointment.status === "no_show" /* NO_SHOW */) {
8728
+ type = "no_show";
8729
+ description = `No-show: ${((_c = appointment.procedureInfo) == null ? void 0 : _c.name) || "Unknown procedure"}`;
8730
+ } else {
8731
+ description = `Appointment ${appointment.status}: ${((_d = appointment.procedureInfo) == null ? void 0 : _d.name) || "Unknown procedure"}`;
8732
+ }
8733
+ return {
8734
+ type,
8735
+ date: appointment.appointmentStartTime.toDate(),
8736
+ description,
8737
+ entityId: appointment.practitionerId,
8738
+ entityName: ((_e = appointment.practitionerInfo) == null ? void 0 : _e.name) || "Unknown"
8739
+ };
8740
+ });
8741
+ return {
8742
+ overview: {
8743
+ totalAppointments: appointments.length,
8744
+ completedAppointments: completed.length,
8745
+ canceledAppointments: canceled.length,
8746
+ noShowAppointments: noShow.length,
8747
+ pendingAppointments: pending.length,
8748
+ confirmedAppointments: confirmed.length,
8749
+ totalRevenue,
8750
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
8751
+ currency,
8752
+ uniquePatients: uniquePatients.size,
8753
+ uniquePractitioners: uniquePractitioners.size,
8754
+ uniqueProcedures: uniqueProcedures.size,
8755
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
8756
+ noShowRate: calculatePercentage(noShow.length, appointments.length)
8757
+ },
8758
+ practitionerMetrics: Array.isArray(practitionerMetrics) ? practitionerMetrics : [],
8759
+ procedureMetrics: Array.isArray(procedureMetrics) ? procedureMetrics : [],
8760
+ cancellationMetrics: Array.isArray(cancellationMetrics) ? cancellationMetrics[0] : cancellationMetrics,
8761
+ noShowMetrics: Array.isArray(noShowMetrics) ? noShowMetrics[0] : noShowMetrics,
8762
+ revenueTrends: [],
8763
+ // TODO: Implement revenue trends
8764
+ timeEfficiency,
8765
+ topProducts,
8766
+ recentActivity
8767
+ };
8768
+ }
8769
+ };
8770
+
8771
+ // src/admin/analytics/analytics.admin.service.ts
8772
+ var AnalyticsAdminService = class {
8773
+ /**
8774
+ * Creates a new AnalyticsAdminService instance
8775
+ *
8776
+ * @param firestore - Admin Firestore instance (optional, defaults to admin.firestore())
8777
+ */
8778
+ constructor(firestore19) {
8779
+ this.db = firestore19 || admin14.firestore();
8780
+ const mockApp = {
8781
+ name: "[DEFAULT]",
8782
+ options: {},
8783
+ automaticDataCollectionEnabled: false
8784
+ };
8785
+ const mockAuth = {};
8786
+ const appointmentService = this.createAppointmentServiceAdapter();
8787
+ this.analyticsService = new AnalyticsService(
8788
+ this.db,
8789
+ // Cast admin Firestore to client Firestore type
8790
+ mockAuth,
8791
+ mockApp,
8792
+ appointmentService
8793
+ );
8794
+ }
8795
+ /**
8796
+ * Creates an adapter for AppointmentService to work with admin SDK
8797
+ */
8798
+ createAppointmentServiceAdapter() {
8799
+ return {
8800
+ searchAppointments: async (params) => {
8801
+ let query2 = this.db.collection(APPOINTMENTS_COLLECTION);
8802
+ if (params.clinicBranchId) {
8803
+ query2 = query2.where("clinicBranchId", "==", params.clinicBranchId);
8804
+ }
8805
+ if (params.practitionerId) {
8806
+ query2 = query2.where("practitionerId", "==", params.practitionerId);
8807
+ }
8808
+ if (params.procedureId) {
8809
+ query2 = query2.where("procedureId", "==", params.procedureId);
8810
+ }
8811
+ if (params.patientId) {
8812
+ query2 = query2.where("patientId", "==", params.patientId);
8813
+ }
8814
+ if (params.startDate) {
8815
+ const startDate = params.startDate instanceof Date ? params.startDate : params.startDate.toDate();
8816
+ const startTimestamp = admin14.firestore.Timestamp.fromDate(startDate);
8817
+ query2 = query2.where("appointmentStartTime", ">=", startTimestamp);
8818
+ }
8819
+ if (params.endDate) {
8820
+ const endDate = params.endDate instanceof Date ? params.endDate : params.endDate.toDate();
8821
+ const endTimestamp = admin14.firestore.Timestamp.fromDate(endDate);
8822
+ query2 = query2.where("appointmentStartTime", "<=", endTimestamp);
8823
+ }
8824
+ const snapshot = await query2.get();
8825
+ const appointments = snapshot.docs.map((doc2) => ({
8826
+ id: doc2.id,
8827
+ ...doc2.data()
8828
+ }));
8829
+ return {
8830
+ appointments,
8831
+ total: appointments.length
8832
+ };
8833
+ }
8834
+ };
8835
+ }
8836
+ // Delegate all methods to the underlying AnalyticsService
8837
+ // We expose them here so they can be called with admin SDK context
8838
+ async getPractitionerAnalytics(practitionerId, dateRange, options) {
8839
+ return this.analyticsService.getPractitionerAnalytics(practitionerId, dateRange, options);
8840
+ }
8841
+ async getProcedureAnalytics(procedureId, dateRange, options) {
8842
+ return this.analyticsService.getProcedureAnalytics(procedureId, dateRange, options);
8843
+ }
8844
+ async getTimeEfficiencyMetrics(filters, dateRange, options) {
8845
+ return this.analyticsService.getTimeEfficiencyMetrics(filters, dateRange, options);
8846
+ }
8847
+ async getTimeEfficiencyMetricsByEntity(groupBy, dateRange, filters) {
8848
+ return this.analyticsService.getTimeEfficiencyMetricsByEntity(groupBy, dateRange, filters);
8849
+ }
8850
+ async getCancellationMetrics(groupBy, dateRange, options) {
8851
+ return this.analyticsService.getCancellationMetrics(groupBy, dateRange, options);
8852
+ }
8853
+ async getNoShowMetrics(groupBy, dateRange, options) {
8854
+ return this.analyticsService.getNoShowMetrics(groupBy, dateRange, options);
8855
+ }
8856
+ async getRevenueMetrics(filters, dateRange, options) {
8857
+ return this.analyticsService.getRevenueMetrics(filters, dateRange, options);
8858
+ }
8859
+ async getRevenueMetricsByEntity(groupBy, dateRange, filters) {
8860
+ return this.analyticsService.getRevenueMetricsByEntity(groupBy, dateRange, filters);
8861
+ }
8862
+ async getProductUsageMetrics(productId, dateRange) {
8863
+ return this.analyticsService.getProductUsageMetrics(productId, dateRange);
8864
+ }
8865
+ async getProductUsageMetricsByEntity(groupBy, dateRange, filters) {
8866
+ return this.analyticsService.getProductUsageMetricsByEntity(groupBy, dateRange, filters);
8867
+ }
8868
+ async getPatientAnalytics(patientId, dateRange) {
8869
+ return this.analyticsService.getPatientAnalytics(patientId, dateRange);
8870
+ }
8871
+ async getPatientBehaviorMetricsByEntity(groupBy, dateRange, filters) {
8872
+ return this.analyticsService.getPatientBehaviorMetricsByEntity(groupBy, dateRange, filters);
8873
+ }
8874
+ async getClinicAnalytics(clinicBranchId, dateRange) {
8875
+ const dashboard = await this.analyticsService.getDashboardData(
8876
+ { clinicBranchId },
8877
+ dateRange
8878
+ );
8879
+ const clinicDoc = await this.db.collection("clinics").doc(clinicBranchId).get();
8880
+ const clinicData = clinicDoc.data();
8881
+ const clinicName = (clinicData == null ? void 0 : clinicData.name) || "Unknown";
8882
+ return {
8883
+ clinicBranchId,
8884
+ clinicName,
8885
+ totalAppointments: dashboard.overview.totalAppointments,
8886
+ completedAppointments: dashboard.overview.completedAppointments,
8887
+ canceledAppointments: dashboard.overview.canceledAppointments,
8888
+ noShowAppointments: dashboard.overview.noShowAppointments,
8889
+ cancellationRate: dashboard.overview.cancellationRate,
8890
+ noShowRate: dashboard.overview.noShowRate,
8891
+ totalRevenue: dashboard.overview.totalRevenue,
8892
+ averageRevenuePerAppointment: dashboard.overview.averageRevenuePerAppointment,
8893
+ currency: dashboard.overview.currency,
8894
+ practitionerCount: dashboard.overview.uniquePractitioners,
8895
+ patientCount: dashboard.overview.uniquePatients,
8896
+ procedureCount: dashboard.overview.uniqueProcedures,
8897
+ topPractitioners: dashboard.practitionerMetrics.slice(0, 5).map((p) => ({
8898
+ practitionerId: p.practitionerId,
8899
+ practitionerName: p.practitionerName,
8900
+ appointmentCount: p.totalAppointments,
8901
+ revenue: p.totalRevenue
8902
+ })),
8903
+ topProcedures: dashboard.procedureMetrics.slice(0, 5).map((p) => ({
8904
+ procedureId: p.procedureId,
8905
+ procedureName: p.procedureName,
8906
+ appointmentCount: p.totalAppointments,
8907
+ revenue: p.totalRevenue
8908
+ }))
8909
+ };
8910
+ }
8911
+ async getDashboardData(filters, dateRange, options) {
8912
+ return this.analyticsService.getDashboardData(filters, dateRange, options);
8913
+ }
8914
+ /**
8915
+ * Expose fetchAppointments for direct access if needed
8916
+ * This method is used internally by AnalyticsService
8917
+ */
8918
+ async fetchAppointments(filters, dateRange) {
8919
+ return this.analyticsService.fetchAppointments(filters, dateRange);
8920
+ }
8921
+ };
8922
+
6766
8923
  // src/admin/booking/booking.calculator.ts
6767
- import { Timestamp } from "firebase/firestore";
8924
+ import { Timestamp as Timestamp3 } from "firebase/firestore";
6768
8925
  import { DateTime as DateTime2 } from "luxon";
6769
8926
  var BookingAvailabilityCalculator = class {
6770
8927
  /**
@@ -6890,8 +9047,8 @@ var BookingAvailabilityCalculator = class {
6890
9047
  const intervalStart = workStart < DateTime2.fromMillis(startDate.getTime(), { zone: tz }) ? DateTime2.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
6891
9048
  const intervalEnd = workEnd > DateTime2.fromMillis(endDate.getTime(), { zone: tz }) ? DateTime2.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
6892
9049
  workingIntervals.push({
6893
- start: Timestamp.fromMillis(intervalStart.toMillis()),
6894
- end: Timestamp.fromMillis(intervalEnd.toMillis())
9050
+ start: Timestamp3.fromMillis(intervalStart.toMillis()),
9051
+ end: Timestamp3.fromMillis(intervalEnd.toMillis())
6895
9052
  });
6896
9053
  if (daySchedule.breaks && daySchedule.breaks.length > 0) {
6897
9054
  for (const breakTime of daySchedule.breaks) {
@@ -6911,8 +9068,8 @@ var BookingAvailabilityCalculator = class {
6911
9068
  ...this.subtractInterval(
6912
9069
  workingIntervals[workingIntervals.length - 1],
6913
9070
  {
6914
- start: Timestamp.fromMillis(breakStart.toMillis()),
6915
- end: Timestamp.fromMillis(breakEnd.toMillis())
9071
+ start: Timestamp3.fromMillis(breakStart.toMillis()),
9072
+ end: Timestamp3.fromMillis(breakEnd.toMillis())
6916
9073
  }
6917
9074
  )
6918
9075
  );
@@ -7022,8 +9179,8 @@ var BookingAvailabilityCalculator = class {
7022
9179
  const intervalStart = workStart < DateTime2.fromMillis(startDate.getTime(), { zone: tz }) ? DateTime2.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
7023
9180
  const intervalEnd = workEnd > DateTime2.fromMillis(endDate.getTime(), { zone: tz }) ? DateTime2.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
7024
9181
  workingIntervals.push({
7025
- start: Timestamp.fromMillis(intervalStart.toMillis()),
7026
- end: Timestamp.fromMillis(intervalEnd.toMillis())
9182
+ start: Timestamp3.fromMillis(intervalStart.toMillis()),
9183
+ end: Timestamp3.fromMillis(intervalEnd.toMillis())
7027
9184
  });
7028
9185
  }
7029
9186
  }
@@ -7103,7 +9260,7 @@ var BookingAvailabilityCalculator = class {
7103
9260
  const isInFuture = slotStart >= earliestBookableTime;
7104
9261
  if (isInFuture && this.isSlotFullyAvailable(slotStart, slotEnd, intervals, tz)) {
7105
9262
  slots.push({
7106
- start: Timestamp.fromMillis(slotStart.toMillis())
9263
+ start: Timestamp3.fromMillis(slotStart.toMillis())
7107
9264
  });
7108
9265
  }
7109
9266
  slotStart = slotStart.plus({ minutes: intervalMinutes });
@@ -7222,13 +9379,13 @@ var BookingAvailabilityCalculator = class {
7222
9379
  BookingAvailabilityCalculator.DEFAULT_INTERVAL_MINUTES = 15;
7223
9380
 
7224
9381
  // src/admin/booking/booking.admin.ts
7225
- import * as admin15 from "firebase-admin";
9382
+ import * as admin16 from "firebase-admin";
7226
9383
 
7227
9384
  // src/admin/documentation-templates/document-manager.admin.ts
7228
- import * as admin14 from "firebase-admin";
9385
+ import * as admin15 from "firebase-admin";
7229
9386
  var DocumentManagerAdminService = class {
7230
- constructor(firestore18) {
7231
- this.db = firestore18;
9387
+ constructor(firestore19) {
9388
+ this.db = firestore19;
7232
9389
  }
7233
9390
  /**
7234
9391
  * Adds operations to a Firestore batch to initialize all linked forms for a new appointment
@@ -7331,10 +9488,10 @@ var DocumentManagerAdminService = class {
7331
9488
  };
7332
9489
  }
7333
9490
  const templateIds = technologyTemplates.map((t) => t.templateId);
7334
- const templatesSnapshot = await this.db.collection(DOCUMENTATION_TEMPLATES_COLLECTION).where(admin14.firestore.FieldPath.documentId(), "in", templateIds).get();
9491
+ const templatesSnapshot = await this.db.collection(DOCUMENTATION_TEMPLATES_COLLECTION).where(admin15.firestore.FieldPath.documentId(), "in", templateIds).get();
7335
9492
  const templatesMap = /* @__PURE__ */ new Map();
7336
- templatesSnapshot.forEach((doc) => {
7337
- templatesMap.set(doc.id, doc.data());
9493
+ templatesSnapshot.forEach((doc2) => {
9494
+ templatesMap.set(doc2.id, doc2.data());
7338
9495
  });
7339
9496
  for (const templateRef of technologyTemplates) {
7340
9497
  const template = templatesMap.get(templateRef.templateId);
@@ -7397,8 +9554,8 @@ var BookingAdmin = class {
7397
9554
  * Creates a new BookingAdmin instance
7398
9555
  * @param firestore - Firestore instance provided by the caller
7399
9556
  */
7400
- constructor(firestore18) {
7401
- this.db = firestore18 || admin15.firestore();
9557
+ constructor(firestore19) {
9558
+ this.db = firestore19 || admin16.firestore();
7402
9559
  this.documentManagerAdmin = new DocumentManagerAdminService(this.db);
7403
9560
  }
7404
9561
  /**
@@ -7420,8 +9577,8 @@ var BookingAdmin = class {
7420
9577
  timeframeStart: timeframe.start instanceof Date ? timeframe.start.toISOString() : timeframe.start.toDate().toISOString(),
7421
9578
  timeframeEnd: timeframe.end instanceof Date ? timeframe.end.toISOString() : timeframe.end.toDate().toISOString()
7422
9579
  });
7423
- const start = timeframe.start instanceof Date ? admin15.firestore.Timestamp.fromDate(timeframe.start) : timeframe.start;
7424
- const end = timeframe.end instanceof Date ? admin15.firestore.Timestamp.fromDate(timeframe.end) : timeframe.end;
9580
+ const start = timeframe.start instanceof Date ? admin16.firestore.Timestamp.fromDate(timeframe.start) : timeframe.start;
9581
+ const end = timeframe.end instanceof Date ? admin16.firestore.Timestamp.fromDate(timeframe.end) : timeframe.end;
7425
9582
  Logger.debug("[BookingAdmin] Fetching clinic data", { clinicId });
7426
9583
  const clinicDoc = await this.db.collection("clinics").doc(clinicId).get();
7427
9584
  if (!clinicDoc.exists) {
@@ -7507,7 +9664,7 @@ var BookingAdmin = class {
7507
9664
  const result = BookingAvailabilityCalculator.calculateSlots(request);
7508
9665
  const availableSlotsResult = {
7509
9666
  availableSlots: result.availableSlots.map((slot) => ({
7510
- start: admin15.firestore.Timestamp.fromMillis(slot.start.toMillis())
9667
+ start: admin16.firestore.Timestamp.fromMillis(slot.start.toMillis())
7511
9668
  }))
7512
9669
  };
7513
9670
  Logger.info(
@@ -7570,14 +9727,14 @@ var BookingAdmin = class {
7570
9727
  endTime: end.toDate().toISOString()
7571
9728
  });
7572
9729
  const MAX_EVENT_DURATION_MS = 24 * 60 * 60 * 1e3;
7573
- const queryStart = admin15.firestore.Timestamp.fromMillis(
9730
+ const queryStart = admin16.firestore.Timestamp.fromMillis(
7574
9731
  start.toMillis() - MAX_EVENT_DURATION_MS
7575
9732
  );
7576
9733
  const eventsRef = this.db.collection(`clinics/${clinicId}/calendar`).where("eventTime.start", ">=", queryStart).where("eventTime.start", "<", end).orderBy("eventTime.start");
7577
9734
  const snapshot = await eventsRef.get();
7578
- const events = snapshot.docs.map((doc) => ({
7579
- ...doc.data(),
7580
- id: doc.id
9735
+ const events = snapshot.docs.map((doc2) => ({
9736
+ ...doc2.data(),
9737
+ id: doc2.id
7581
9738
  })).filter((event) => {
7582
9739
  return event.eventTime.end.toMillis() > start.toMillis();
7583
9740
  });
@@ -7616,14 +9773,14 @@ var BookingAdmin = class {
7616
9773
  endTime: end.toDate().toISOString()
7617
9774
  });
7618
9775
  const MAX_EVENT_DURATION_MS = 24 * 60 * 60 * 1e3;
7619
- const queryStart = admin15.firestore.Timestamp.fromMillis(
9776
+ const queryStart = admin16.firestore.Timestamp.fromMillis(
7620
9777
  start.toMillis() - MAX_EVENT_DURATION_MS
7621
9778
  );
7622
9779
  const eventsRef = this.db.collection(`practitioners/${practitionerId}/calendar`).where("eventTime.start", ">=", queryStart).where("eventTime.start", "<", end).orderBy("eventTime.start");
7623
9780
  const snapshot = await eventsRef.get();
7624
- const events = snapshot.docs.map((doc) => ({
7625
- ...doc.data(),
7626
- id: doc.id
9781
+ const events = snapshot.docs.map((doc2) => ({
9782
+ ...doc2.data(),
9783
+ id: doc2.id
7627
9784
  })).filter((event) => {
7628
9785
  return event.eventTime.end.toMillis() > start.toMillis();
7629
9786
  });
@@ -7685,8 +9842,8 @@ var BookingAdmin = class {
7685
9842
  `[BookingAdmin] Orchestrating appointment creation for patient ${data.patientId} by user ${authenticatedUserId}`
7686
9843
  );
7687
9844
  const batch = this.db.batch();
7688
- const adminTsNow = admin15.firestore.Timestamp.now();
7689
- const serverTimestampValue = admin15.firestore.FieldValue.serverTimestamp();
9845
+ const adminTsNow = admin16.firestore.Timestamp.now();
9846
+ const serverTimestampValue = admin16.firestore.FieldValue.serverTimestamp();
7690
9847
  try {
7691
9848
  if (!data.patientId || !data.procedureId || !data.appointmentStartTime || !data.appointmentEndTime) {
7692
9849
  return {
@@ -7783,7 +9940,7 @@ var BookingAdmin = class {
7783
9940
  fullName: `${(patientSensitiveData == null ? void 0 : patientSensitiveData.firstName) || ""} ${(patientSensitiveData == null ? void 0 : patientSensitiveData.lastName) || ""}`.trim() || patientProfileData.displayName,
7784
9941
  email: (patientSensitiveData == null ? void 0 : patientSensitiveData.email) || "",
7785
9942
  phone: (patientSensitiveData == null ? void 0 : patientSensitiveData.phoneNumber) || patientProfileData.phoneNumber || null,
7786
- dateOfBirth: (patientSensitiveData == null ? void 0 : patientSensitiveData.dateOfBirth) || patientProfileData.dateOfBirth || admin15.firestore.Timestamp.now(),
9943
+ dateOfBirth: (patientSensitiveData == null ? void 0 : patientSensitiveData.dateOfBirth) || patientProfileData.dateOfBirth || admin16.firestore.Timestamp.now(),
7787
9944
  gender: (patientSensitiveData == null ? void 0 : patientSensitiveData.gender) || "other" /* OTHER */
7788
9945
  };
7789
9946
  const newAppointmentId = this.db.collection(APPOINTMENTS_COLLECTION).doc().id;
@@ -8048,7 +10205,7 @@ var BookingAdmin = class {
8048
10205
  };
8049
10206
 
8050
10207
  // src/admin/free-consultation/free-consultation-utils.admin.ts
8051
- import * as admin16 from "firebase-admin";
10208
+ import * as admin17 from "firebase-admin";
8052
10209
 
8053
10210
  // src/backoffice/types/category.types.ts
8054
10211
  var CATEGORIES_COLLECTION = "backoffice_categories";
@@ -8061,10 +10218,10 @@ var TECHNOLOGIES_COLLECTION = "technologies";
8061
10218
 
8062
10219
  // src/admin/free-consultation/free-consultation-utils.admin.ts
8063
10220
  async function freeConsultationInfrastructure(db) {
8064
- const firestore18 = db || admin16.firestore();
10221
+ const firestore19 = db || admin17.firestore();
8065
10222
  try {
8066
10223
  console.log("[freeConsultationInfrastructure] Checking free consultation infrastructure...");
8067
- const technologyRef = firestore18.collection(TECHNOLOGIES_COLLECTION).doc("free-consultation-tech");
10224
+ const technologyRef = firestore19.collection(TECHNOLOGIES_COLLECTION).doc("free-consultation-tech");
8068
10225
  const technologyDoc = await technologyRef.get();
8069
10226
  if (technologyDoc.exists) {
8070
10227
  console.log(
@@ -8073,7 +10230,7 @@ async function freeConsultationInfrastructure(db) {
8073
10230
  return true;
8074
10231
  }
8075
10232
  console.log("[freeConsultationInfrastructure] Creating free consultation infrastructure...");
8076
- await createFreeConsultationInfrastructure(firestore18);
10233
+ await createFreeConsultationInfrastructure(firestore19);
8077
10234
  console.log(
8078
10235
  "[freeConsultationInfrastructure] Successfully created free consultation infrastructure"
8079
10236
  );
@@ -8270,8 +10427,8 @@ var PractitionerInviteMailingService = class extends BaseMailingService {
8270
10427
  * @param firestore Firestore instance provided by the caller
8271
10428
  * @param mailgunClient Mailgun client instance (mailgun.js v10+) provided by the caller
8272
10429
  */
8273
- constructor(firestore18, mailgunClient) {
8274
- super(firestore18, mailgunClient);
10430
+ constructor(firestore19, mailgunClient) {
10431
+ super(firestore19, mailgunClient);
8275
10432
  this.DEFAULT_REGISTRATION_URL = "https://metaesthetics.net/register";
8276
10433
  this.DEFAULT_SUBJECT = "You've Been Invited to Join as a Practitioner";
8277
10434
  this.DEFAULT_MAILGUN_DOMAIN = "mg.metaesthetics.net";
@@ -9134,8 +11291,8 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
9134
11291
  * @param firestore Firestore instance provided by the caller
9135
11292
  * @param mailgunClient Mailgun client instance (mailgun.js v10+) provided by the caller
9136
11293
  */
9137
- constructor(firestore18, mailgunClient) {
9138
- super(firestore18, mailgunClient);
11294
+ constructor(firestore19, mailgunClient) {
11295
+ super(firestore19, mailgunClient);
9139
11296
  this.DEFAULT_MAILGUN_DOMAIN = "mg.metaesthetics.net";
9140
11297
  this.DEFAULT_FROM_ADDRESS = "MetaEstetics <no-reply@mg.metaesthetics.net>";
9141
11298
  }
@@ -9478,8 +11635,8 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
9478
11635
  */
9479
11636
  async fetchPractitionerById(practitionerId) {
9480
11637
  try {
9481
- const doc = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
9482
- return doc.exists ? doc.data() : null;
11638
+ const doc2 = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
11639
+ return doc2.exists ? doc2.data() : null;
9483
11640
  } catch (error) {
9484
11641
  Logger.error(
9485
11642
  "[ExistingPractitionerInviteMailingService] Error fetching practitioner:",
@@ -9495,8 +11652,8 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
9495
11652
  */
9496
11653
  async fetchClinicById(clinicId) {
9497
11654
  try {
9498
- const doc = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
9499
- return doc.exists ? doc.data() : null;
11655
+ const doc2 = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
11656
+ return doc2.exists ? doc2.data() : null;
9500
11657
  } catch (error) {
9501
11658
  Logger.error(
9502
11659
  "[ExistingPractitionerInviteMailingService] Error fetching clinic:",
@@ -9508,14 +11665,14 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
9508
11665
  };
9509
11666
 
9510
11667
  // src/admin/users/user-profile.admin.ts
9511
- import * as admin17 from "firebase-admin";
11668
+ import * as admin18 from "firebase-admin";
9512
11669
  var UserProfileAdminService = class {
9513
11670
  /**
9514
11671
  * Constructor for UserProfileAdminService
9515
11672
  * @param firestore Optional Firestore instance. If not provided, uses the default admin SDK instance.
9516
11673
  */
9517
- constructor(firestore18) {
9518
- this.db = firestore18 || admin17.firestore();
11674
+ constructor(firestore19) {
11675
+ this.db = firestore19 || admin18.firestore();
9519
11676
  }
9520
11677
  /**
9521
11678
  * Creates a blank user profile with minimal information
@@ -9532,9 +11689,9 @@ var UserProfileAdminService = class {
9532
11689
  roles: [],
9533
11690
  // Empty roles array as requested
9534
11691
  isAnonymous: authUserData.isAnonymous,
9535
- createdAt: admin17.firestore.FieldValue.serverTimestamp(),
9536
- updatedAt: admin17.firestore.FieldValue.serverTimestamp(),
9537
- lastLoginAt: admin17.firestore.FieldValue.serverTimestamp()
11692
+ createdAt: admin18.firestore.FieldValue.serverTimestamp(),
11693
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp(),
11694
+ lastLoginAt: admin18.firestore.FieldValue.serverTimestamp()
9538
11695
  };
9539
11696
  try {
9540
11697
  const userRef = this.db.collection(USERS_COLLECTION).doc(authUserData.uid);
@@ -9610,8 +11767,8 @@ var UserProfileAdminService = class {
9610
11767
  clinics: mergedProfileData.clinics || [],
9611
11768
  doctorIds: mergedProfileData.doctorIds || [],
9612
11769
  clinicIds: mergedProfileData.clinicIds || [],
9613
- createdAt: admin17.firestore.FieldValue.serverTimestamp(),
9614
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
11770
+ createdAt: admin18.firestore.FieldValue.serverTimestamp(),
11771
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp()
9615
11772
  };
9616
11773
  await patientProfileRef.set(patientProfileData);
9617
11774
  patientProfile = {
@@ -9650,8 +11807,8 @@ var UserProfileAdminService = class {
9650
11807
  };
9651
11808
  const sensitiveInfoData = {
9652
11809
  ...mergedSensitiveData,
9653
- createdAt: admin17.firestore.FieldValue.serverTimestamp(),
9654
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
11810
+ createdAt: admin18.firestore.FieldValue.serverTimestamp(),
11811
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp()
9655
11812
  // Leave dateOfBirth as is
9656
11813
  };
9657
11814
  await sensitiveInfoRef.set(sensitiveInfoData);
@@ -9677,7 +11834,7 @@ var UserProfileAdminService = class {
9677
11834
  contraindications: [],
9678
11835
  allergies: [],
9679
11836
  currentMedications: [],
9680
- lastUpdated: admin17.firestore.FieldValue.serverTimestamp(),
11837
+ lastUpdated: admin18.firestore.FieldValue.serverTimestamp(),
9681
11838
  updatedBy: userId
9682
11839
  };
9683
11840
  await medicalInfoRef.set(medicalInfoData);
@@ -9694,14 +11851,14 @@ var UserProfileAdminService = class {
9694
11851
  const batch = this.db.batch();
9695
11852
  if (!userData.roles.includes("patient" /* PATIENT */)) {
9696
11853
  batch.update(userRef, {
9697
- roles: admin17.firestore.FieldValue.arrayUnion("patient" /* PATIENT */),
9698
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
11854
+ roles: admin18.firestore.FieldValue.arrayUnion("patient" /* PATIENT */),
11855
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp()
9699
11856
  });
9700
11857
  }
9701
11858
  if (!userData.patientProfile) {
9702
11859
  batch.update(userRef, {
9703
11860
  patientProfile: patientProfileId,
9704
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
11861
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp()
9705
11862
  });
9706
11863
  }
9707
11864
  await batch.commit();
@@ -9742,8 +11899,8 @@ var UserProfileAdminService = class {
9742
11899
  const userData = userDoc.data();
9743
11900
  if (!userData.roles.includes("clinic_admin" /* CLINIC_ADMIN */)) {
9744
11901
  await userRef.update({
9745
- roles: admin17.firestore.FieldValue.arrayUnion("clinic_admin" /* CLINIC_ADMIN */),
9746
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
11902
+ roles: admin18.firestore.FieldValue.arrayUnion("clinic_admin" /* CLINIC_ADMIN */),
11903
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp()
9747
11904
  });
9748
11905
  }
9749
11906
  const updatedUserDoc = await userRef.get();
@@ -9774,8 +11931,8 @@ var UserProfileAdminService = class {
9774
11931
  const userData = userDoc.data();
9775
11932
  if (!userData.roles.includes("practitioner" /* PRACTITIONER */)) {
9776
11933
  await userRef.update({
9777
- roles: admin17.firestore.FieldValue.arrayUnion("practitioner" /* PRACTITIONER */),
9778
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
11934
+ roles: admin18.firestore.FieldValue.arrayUnion("practitioner" /* PRACTITIONER */),
11935
+ updatedAt: admin18.firestore.FieldValue.serverTimestamp()
9779
11936
  });
9780
11937
  }
9781
11938
  const updatedUserDoc = await userRef.get();
@@ -9794,6 +11951,8 @@ var UserProfileAdminService = class {
9794
11951
  console.log("[Admin Module] Initialized and services exported.");
9795
11952
  TimestampUtils.enableServerMode();
9796
11953
  export {
11954
+ ANALYTICS_COLLECTION,
11955
+ AnalyticsAdminService,
9797
11956
  AppointmentAggregationService,
9798
11957
  AppointmentMailingService,
9799
11958
  AppointmentStatus,
@@ -9801,19 +11960,26 @@ export {
9801
11960
  BillingTransactionType,
9802
11961
  BookingAdmin,
9803
11962
  BookingAvailabilityCalculator,
11963
+ CANCELLATION_ANALYTICS_SUBCOLLECTION,
11964
+ CLINICS_COLLECTION,
11965
+ CLINIC_ANALYTICS_SUBCOLLECTION,
9804
11966
  CalendarAdminService,
9805
11967
  ClinicAggregationService,
11968
+ DASHBOARD_ANALYTICS_SUBCOLLECTION,
9806
11969
  DocumentManagerAdminService,
9807
11970
  ExistingPractitionerInviteMailingService,
9808
11971
  FilledFormsAggregationService,
9809
11972
  Logger,
9810
11973
  NOTIFICATIONS_COLLECTION,
11974
+ NO_SHOW_ANALYTICS_SUBCOLLECTION,
9811
11975
  NotificationStatus,
9812
11976
  NotificationType,
9813
11977
  NotificationsAdmin,
9814
11978
  PATIENTS_COLLECTION,
9815
11979
  PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME,
9816
11980
  PATIENT_SENSITIVE_INFO_COLLECTION,
11981
+ PRACTITIONER_ANALYTICS_SUBCOLLECTION,
11982
+ PROCEDURE_ANALYTICS_SUBCOLLECTION,
9817
11983
  PatientAggregationService,
9818
11984
  PatientInstructionStatus,
9819
11985
  PatientRequirementOverallStatus,
@@ -9824,8 +11990,10 @@ export {
9824
11990
  PractitionerInviteStatus,
9825
11991
  PractitionerTokenStatus,
9826
11992
  ProcedureAggregationService,
11993
+ REVENUE_ANALYTICS_SUBCOLLECTION,
9827
11994
  ReviewsAggregationService,
9828
11995
  SubscriptionStatus,
11996
+ TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION,
9829
11997
  UserProfileAdminService,
9830
11998
  UserRole,
9831
11999
  freeConsultationInfrastructure