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