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