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