@blackcode_sa/metaestetics-api 1.13.3 → 1.13.5
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 +15 -28
- package/dist/admin/index.d.ts +15 -28
- package/dist/index.d.mts +18 -30
- package/dist/index.d.ts +18 -30
- package/dist/index.js +11 -3
- package/dist/index.mjs +11 -3
- package/package.json +121 -119
- package/src/__mocks__/firstore.ts +10 -10
- package/src/admin/aggregation/README.md +79 -79
- package/src/admin/aggregation/appointment/README.md +128 -128
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +1984 -1984
- package/src/admin/aggregation/appointment/index.ts +1 -1
- package/src/admin/aggregation/clinic/README.md +52 -52
- package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +703 -703
- package/src/admin/aggregation/clinic/index.ts +1 -1
- package/src/admin/aggregation/forms/README.md +13 -13
- package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
- package/src/admin/aggregation/forms/index.ts +1 -1
- package/src/admin/aggregation/index.ts +8 -8
- package/src/admin/aggregation/patient/README.md +27 -27
- package/src/admin/aggregation/patient/index.ts +1 -1
- package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
- package/src/admin/aggregation/practitioner/README.md +42 -42
- package/src/admin/aggregation/practitioner/index.ts +1 -1
- package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
- package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
- package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
- package/src/admin/aggregation/procedure/README.md +43 -43
- package/src/admin/aggregation/procedure/index.ts +1 -1
- package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
- package/src/admin/aggregation/reviews/index.ts +1 -1
- package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +689 -689
- package/src/admin/analytics/analytics.admin.service.ts +278 -278
- package/src/admin/analytics/index.ts +2 -2
- package/src/admin/booking/README.md +125 -125
- package/src/admin/booking/booking.admin.ts +1037 -1037
- package/src/admin/booking/booking.calculator.ts +712 -712
- package/src/admin/booking/booking.types.ts +59 -59
- package/src/admin/booking/index.ts +3 -3
- package/src/admin/booking/timezones-problem.md +185 -185
- package/src/admin/calendar/README.md +7 -7
- package/src/admin/calendar/calendar.admin.service.ts +345 -345
- package/src/admin/calendar/index.ts +1 -1
- package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
- package/src/admin/documentation-templates/index.ts +1 -1
- package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
- package/src/admin/free-consultation/index.ts +1 -1
- package/src/admin/index.ts +81 -81
- package/src/admin/logger/index.ts +78 -78
- package/src/admin/mailing/README.md +95 -95
- package/src/admin/mailing/appointment/appointment.mailing.service.ts +732 -732
- package/src/admin/mailing/appointment/index.ts +1 -1
- package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
- package/src/admin/mailing/base.mailing.service.ts +208 -208
- package/src/admin/mailing/index.ts +3 -3
- package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
- package/src/admin/mailing/practitionerInvite/index.ts +2 -2
- package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
- package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
- package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
- package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
- package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
- package/src/admin/notifications/index.ts +1 -1
- package/src/admin/notifications/notifications.admin.ts +710 -710
- package/src/admin/requirements/README.md +128 -128
- package/src/admin/requirements/index.ts +1 -1
- package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
- package/src/admin/users/index.ts +1 -1
- package/src/admin/users/user-profile.admin.ts +405 -405
- package/src/backoffice/constants/certification.constants.ts +13 -13
- package/src/backoffice/constants/index.ts +1 -1
- package/src/backoffice/errors/backoffice.errors.ts +181 -181
- package/src/backoffice/errors/index.ts +1 -1
- package/src/backoffice/expo-safe/README.md +26 -26
- package/src/backoffice/expo-safe/index.ts +41 -41
- package/src/backoffice/index.ts +5 -5
- package/src/backoffice/services/FIXES_README.md +102 -102
- package/src/backoffice/services/README.md +57 -57
- package/src/backoffice/services/analytics.service.proposal.md +863 -863
- package/src/backoffice/services/analytics.service.summary.md +143 -143
- package/src/backoffice/services/brand.service.ts +256 -256
- package/src/backoffice/services/category.service.ts +384 -384
- package/src/backoffice/services/constants.service.ts +385 -385
- package/src/backoffice/services/documentation-template.service.ts +202 -202
- package/src/backoffice/services/index.ts +10 -10
- package/src/backoffice/services/migrate-products.ts +116 -116
- package/src/backoffice/services/product.service.ts +553 -553
- package/src/backoffice/services/requirement.service.ts +235 -235
- package/src/backoffice/services/subcategory.service.ts +461 -461
- package/src/backoffice/services/technology.service.ts +1151 -1151
- package/src/backoffice/types/README.md +12 -12
- package/src/backoffice/types/admin-constants.types.ts +69 -69
- package/src/backoffice/types/brand.types.ts +29 -29
- package/src/backoffice/types/category.types.ts +67 -67
- package/src/backoffice/types/documentation-templates.types.ts +28 -28
- package/src/backoffice/types/index.ts +10 -10
- package/src/backoffice/types/procedure-product.types.ts +38 -38
- package/src/backoffice/types/product.types.ts +240 -240
- package/src/backoffice/types/requirement.types.ts +63 -63
- package/src/backoffice/types/static/README.md +18 -18
- package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
- package/src/backoffice/types/static/certification.types.ts +37 -37
- package/src/backoffice/types/static/contraindication.types.ts +19 -19
- package/src/backoffice/types/static/index.ts +6 -6
- package/src/backoffice/types/static/pricing.types.ts +16 -16
- package/src/backoffice/types/static/procedure-family.types.ts +14 -14
- package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
- package/src/backoffice/types/subcategory.types.ts +34 -34
- package/src/backoffice/types/technology.types.ts +168 -168
- package/src/backoffice/validations/index.ts +1 -1
- package/src/backoffice/validations/schemas.ts +164 -164
- package/src/config/__mocks__/firebase.ts +99 -99
- package/src/config/firebase.ts +78 -78
- package/src/config/index.ts +9 -9
- package/src/errors/auth.error.ts +6 -6
- package/src/errors/auth.errors.ts +200 -200
- package/src/errors/clinic.errors.ts +32 -32
- package/src/errors/firebase.errors.ts +47 -47
- package/src/errors/user.errors.ts +99 -99
- package/src/index.backup.ts +407 -407
- package/src/index.ts +6 -6
- package/src/locales/en.ts +31 -31
- package/src/recommender/admin/index.ts +1 -1
- package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
- package/src/recommender/front/index.ts +1 -1
- package/src/recommender/front/services/onboarding.service.ts +5 -5
- package/src/recommender/front/services/recommender.service.ts +3 -3
- package/src/recommender/index.ts +1 -1
- package/src/services/PATIENTAUTH.MD +197 -197
- package/src/services/README.md +106 -106
- package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
- package/src/services/__tests__/auth/auth.setup.ts +293 -293
- package/src/services/__tests__/auth.service.test.ts +346 -346
- package/src/services/__tests__/base.service.test.ts +77 -77
- package/src/services/__tests__/user.service.test.ts +528 -528
- package/src/services/analytics/ARCHITECTURE.md +199 -199
- package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -225
- package/src/services/analytics/GROUPED_ANALYTICS.md +501 -501
- package/src/services/analytics/QUICK_START.md +393 -393
- package/src/services/analytics/README.md +304 -304
- package/src/services/analytics/SUMMARY.md +141 -141
- package/src/services/analytics/TRENDS.md +380 -380
- package/src/services/analytics/USAGE_GUIDE.md +518 -518
- package/src/services/analytics/analytics-cloud.service.ts +222 -222
- package/src/services/analytics/analytics.service.ts +2142 -2142
- package/src/services/analytics/index.ts +4 -4
- package/src/services/analytics/review-analytics.service.ts +941 -941
- package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -138
- package/src/services/analytics/utils/cost-calculation.utils.ts +182 -182
- package/src/services/analytics/utils/grouping.utils.ts +434 -434
- package/src/services/analytics/utils/stored-analytics.utils.ts +347 -347
- package/src/services/analytics/utils/time-calculation.utils.ts +186 -186
- package/src/services/analytics/utils/trend-calculation.utils.ts +200 -200
- package/src/services/appointment/README.md +17 -17
- package/src/services/appointment/appointment.service.ts +2558 -2558
- package/src/services/appointment/index.ts +1 -1
- package/src/services/appointment/utils/appointment.utils.ts +552 -552
- package/src/services/appointment/utils/extended-procedure.utils.ts +314 -314
- package/src/services/appointment/utils/form-initialization.utils.ts +225 -225
- package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
- package/src/services/appointment/utils/zone-management.utils.ts +353 -353
- package/src/services/appointment/utils/zone-photo.utils.ts +152 -152
- package/src/services/auth/auth.service.ts +989 -989
- package/src/services/auth/auth.v2.service.ts +961 -961
- package/src/services/auth/index.ts +7 -7
- package/src/services/auth/utils/error.utils.ts +90 -90
- package/src/services/auth/utils/firebase.utils.ts +49 -49
- package/src/services/auth/utils/index.ts +21 -21
- package/src/services/auth/utils/practitioner.utils.ts +125 -125
- package/src/services/base.service.ts +41 -41
- package/src/services/calendar/calendar.service.ts +1077 -1077
- package/src/services/calendar/calendar.v2.service.ts +1683 -1683
- package/src/services/calendar/calendar.v3.service.ts +313 -313
- package/src/services/calendar/externalCalendar.service.ts +178 -178
- package/src/services/calendar/index.ts +5 -5
- package/src/services/calendar/synced-calendars.service.ts +743 -743
- package/src/services/calendar/utils/appointment.utils.ts +265 -265
- package/src/services/calendar/utils/calendar-event.utils.ts +646 -646
- package/src/services/calendar/utils/clinic.utils.ts +237 -237
- package/src/services/calendar/utils/docs.utils.ts +157 -157
- package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
- package/src/services/calendar/utils/index.ts +8 -8
- package/src/services/calendar/utils/patient.utils.ts +198 -198
- package/src/services/calendar/utils/practitioner.utils.ts +221 -221
- package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
- package/src/services/clinic/README.md +204 -204
- package/src/services/clinic/__tests__/clinic-admin.service.test.ts +287 -287
- package/src/services/clinic/__tests__/clinic-group.service.test.ts +352 -352
- package/src/services/clinic/__tests__/clinic.service.test.ts +354 -354
- package/src/services/clinic/billing-transactions.service.ts +217 -217
- package/src/services/clinic/clinic-admin.service.ts +202 -202
- package/src/services/clinic/clinic-group.service.ts +310 -310
- package/src/services/clinic/clinic.service.ts +708 -708
- package/src/services/clinic/index.ts +5 -5
- package/src/services/clinic/practitioner-invite.service.ts +519 -519
- package/src/services/clinic/utils/admin.utils.ts +551 -551
- package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
- package/src/services/clinic/utils/clinic.utils.ts +949 -949
- package/src/services/clinic/utils/filter.utils.d.ts +23 -23
- package/src/services/clinic/utils/filter.utils.ts +446 -446
- package/src/services/clinic/utils/index.ts +11 -11
- package/src/services/clinic/utils/photos.utils.ts +188 -188
- package/src/services/clinic/utils/search.utils.ts +84 -84
- package/src/services/clinic/utils/tag.utils.ts +124 -124
- package/src/services/documentation-templates/documentation-template.service.ts +537 -537
- package/src/services/documentation-templates/filled-document.service.ts +587 -587
- package/src/services/documentation-templates/index.ts +2 -2
- package/src/services/index.ts +14 -14
- package/src/services/media/index.ts +1 -1
- package/src/services/media/media.service.ts +418 -418
- package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
- package/src/services/notifications/index.ts +1 -1
- package/src/services/notifications/notification.service.ts +215 -215
- package/src/services/patient/README.md +48 -48
- package/src/services/patient/To-Do.md +43 -43
- package/src/services/patient/__tests__/patient.service.test.ts +294 -294
- package/src/services/patient/index.ts +2 -2
- package/src/services/patient/patient.service.ts +883 -883
- package/src/services/patient/patientRequirements.service.ts +285 -285
- package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
- package/src/services/patient/utils/clinic.utils.ts +80 -80
- package/src/services/patient/utils/docs.utils.ts +142 -142
- package/src/services/patient/utils/index.ts +9 -9
- package/src/services/patient/utils/location.utils.ts +126 -126
- package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
- package/src/services/patient/utils/medical.utils.ts +458 -458
- package/src/services/patient/utils/practitioner.utils.ts +260 -260
- package/src/services/patient/utils/profile.utils.ts +510 -510
- package/src/services/patient/utils/sensitive.utils.ts +260 -260
- package/src/services/patient/utils/token.utils.ts +211 -211
- package/src/services/practitioner/README.md +145 -145
- package/src/services/practitioner/index.ts +1 -1
- package/src/services/practitioner/practitioner.service.ts +1742 -1742
- package/src/services/procedure/README.md +163 -163
- package/src/services/procedure/index.ts +1 -1
- package/src/services/procedure/procedure.service.ts +2200 -2191
- package/src/services/reviews/index.ts +1 -1
- package/src/services/reviews/reviews.service.ts +734 -734
- package/src/services/user/index.ts +1 -1
- package/src/services/user/user.service.ts +489 -489
- package/src/services/user/user.v2.service.ts +466 -466
- package/src/types/analytics/analytics.types.ts +597 -597
- package/src/types/analytics/grouped-analytics.types.ts +173 -173
- package/src/types/analytics/index.ts +4 -4
- package/src/types/analytics/stored-analytics.types.ts +137 -137
- package/src/types/appointment/index.ts +480 -480
- package/src/types/calendar/index.ts +258 -258
- package/src/types/calendar/synced-calendar.types.ts +66 -66
- package/src/types/clinic/index.ts +498 -489
- package/src/types/clinic/practitioner-invite.types.ts +91 -91
- package/src/types/clinic/preferences.types.ts +159 -159
- package/src/types/clinic/to-do +3 -3
- package/src/types/documentation-templates/index.ts +308 -308
- package/src/types/index.ts +47 -47
- package/src/types/notifications/README.md +77 -77
- package/src/types/notifications/index.ts +286 -286
- package/src/types/patient/aesthetic-analysis.types.ts +66 -66
- package/src/types/patient/allergies.ts +58 -58
- package/src/types/patient/index.ts +275 -275
- package/src/types/patient/medical-info.types.ts +152 -152
- package/src/types/patient/patient-requirements.ts +92 -92
- package/src/types/patient/token.types.ts +61 -61
- package/src/types/practitioner/index.ts +206 -206
- package/src/types/procedure/index.ts +181 -181
- package/src/types/profile/index.ts +39 -39
- package/src/types/reviews/index.ts +132 -132
- package/src/types/tz-lookup.d.ts +4 -4
- package/src/types/user/index.ts +38 -38
- package/src/utils/TIMESTAMPS.md +176 -176
- package/src/utils/TimestampUtils.ts +241 -241
- package/src/utils/index.ts +1 -1
- package/src/validations/appointment.schema.ts +574 -574
- package/src/validations/calendar.schema.ts +225 -225
- package/src/validations/clinic.schema.ts +494 -493
- package/src/validations/common.schema.ts +25 -25
- package/src/validations/documentation-templates/index.ts +1 -1
- package/src/validations/documentation-templates/template.schema.ts +220 -220
- package/src/validations/documentation-templates.schema.ts +10 -10
- package/src/validations/index.ts +20 -20
- package/src/validations/media.schema.ts +10 -10
- package/src/validations/notification.schema.ts +90 -90
- package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
- package/src/validations/patient/medical-info.schema.ts +125 -125
- package/src/validations/patient/patient-requirements.schema.ts +84 -84
- package/src/validations/patient/token.schema.ts +29 -29
- package/src/validations/patient.schema.ts +217 -217
- package/src/validations/practitioner.schema.ts +222 -222
- package/src/validations/procedure-product.schema.ts +41 -41
- package/src/validations/procedure.schema.ts +124 -124
- package/src/validations/profile-info.schema.ts +41 -41
- package/src/validations/reviews.schema.ts +195 -195
- package/src/validations/schemas.ts +104 -104
- package/src/validations/shared.schema.ts +78 -78
|
@@ -1,2142 +1,2142 @@
|
|
|
1
|
-
import { Firestore, collection, query, where, getDocs, Timestamp } from 'firebase/firestore';
|
|
2
|
-
import { Auth } from 'firebase/auth';
|
|
3
|
-
import { FirebaseApp } from 'firebase/app';
|
|
4
|
-
import { BaseService } from '../base.service';
|
|
5
|
-
import { Appointment, AppointmentStatus, APPOINTMENTS_COLLECTION, PaymentStatus } from '../../types/appointment';
|
|
6
|
-
import { AppointmentService } from '../appointment/appointment.service';
|
|
7
|
-
import {
|
|
8
|
-
PractitionerAnalytics,
|
|
9
|
-
ProcedureAnalytics,
|
|
10
|
-
TimeEfficiencyMetrics,
|
|
11
|
-
CancellationMetrics,
|
|
12
|
-
NoShowMetrics,
|
|
13
|
-
RevenueMetrics,
|
|
14
|
-
RevenueTrend,
|
|
15
|
-
DurationTrend,
|
|
16
|
-
AppointmentTrend,
|
|
17
|
-
CancellationRateTrend,
|
|
18
|
-
ProductUsageMetrics,
|
|
19
|
-
ProductRevenueMetrics,
|
|
20
|
-
ProductUsageByProcedure,
|
|
21
|
-
PatientAnalytics,
|
|
22
|
-
PatientLifetimeValueMetrics,
|
|
23
|
-
PatientRetentionMetrics,
|
|
24
|
-
CostPerPatientMetrics,
|
|
25
|
-
PaymentStatusBreakdown,
|
|
26
|
-
ClinicAnalytics,
|
|
27
|
-
ClinicComparisonMetrics,
|
|
28
|
-
ProcedurePopularity,
|
|
29
|
-
ProcedureProfitability,
|
|
30
|
-
CancellationReasonStats,
|
|
31
|
-
DashboardAnalytics,
|
|
32
|
-
AnalyticsDateRange,
|
|
33
|
-
AnalyticsFilters,
|
|
34
|
-
GroupingPeriod,
|
|
35
|
-
TrendPeriod,
|
|
36
|
-
EntityType,
|
|
37
|
-
} from '../../types/analytics';
|
|
38
|
-
|
|
39
|
-
// Import utility functions
|
|
40
|
-
import { calculateAppointmentCost, calculateTotalRevenue, extractProductUsage } from './utils/cost-calculation.utils';
|
|
41
|
-
import {
|
|
42
|
-
calculateTimeEfficiency,
|
|
43
|
-
calculateAverageTimeMetrics,
|
|
44
|
-
calculateEfficiencyDistribution,
|
|
45
|
-
calculateCancellationLeadTime,
|
|
46
|
-
} from './utils/time-calculation.utils';
|
|
47
|
-
import {
|
|
48
|
-
filterByDateRange,
|
|
49
|
-
filterAppointments,
|
|
50
|
-
getCompletedAppointments,
|
|
51
|
-
getCanceledAppointments,
|
|
52
|
-
getNoShowAppointments,
|
|
53
|
-
getActiveAppointments,
|
|
54
|
-
calculatePercentage,
|
|
55
|
-
} from './utils/appointment-filtering.utils';
|
|
56
|
-
import {
|
|
57
|
-
readStoredPractitionerAnalytics,
|
|
58
|
-
readStoredProcedureAnalytics,
|
|
59
|
-
readStoredClinicAnalytics,
|
|
60
|
-
readStoredDashboardAnalytics,
|
|
61
|
-
readStoredTimeEfficiencyMetrics,
|
|
62
|
-
readStoredRevenueMetrics,
|
|
63
|
-
readStoredCancellationMetrics,
|
|
64
|
-
readStoredNoShowMetrics,
|
|
65
|
-
} from './utils/stored-analytics.utils';
|
|
66
|
-
import {
|
|
67
|
-
calculateGroupedRevenueMetrics,
|
|
68
|
-
calculateGroupedProductUsageMetrics,
|
|
69
|
-
calculateGroupedTimeEfficiencyMetrics,
|
|
70
|
-
calculateGroupedPatientBehaviorMetrics,
|
|
71
|
-
} from './utils/grouping.utils';
|
|
72
|
-
import {
|
|
73
|
-
groupAppointmentsByPeriod,
|
|
74
|
-
generatePeriods,
|
|
75
|
-
getTrendChange,
|
|
76
|
-
type TrendPeriod as TrendPeriodType,
|
|
77
|
-
} from './utils/trend-calculation.utils';
|
|
78
|
-
import { ReadStoredAnalyticsOptions, AnalyticsPeriod } from '../../types/analytics';
|
|
79
|
-
import {
|
|
80
|
-
GroupedRevenueMetrics,
|
|
81
|
-
GroupedProductUsageMetrics,
|
|
82
|
-
GroupedTimeEfficiencyMetrics,
|
|
83
|
-
GroupedPatientBehaviorMetrics,
|
|
84
|
-
} from '../../types/analytics/grouped-analytics.types';
|
|
85
|
-
import { ReviewAnalyticsService, ReviewAnalyticsMetrics, OverallReviewAverages, ReviewDetail } from './review-analytics.service';
|
|
86
|
-
import { ReviewTrend } from '../../types/analytics';
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* AnalyticsService provides comprehensive financial and analytical intelligence
|
|
90
|
-
* for the Clinic Admin app, including metrics about doctors, procedures,
|
|
91
|
-
* appointments, patients, products, and clinic operations.
|
|
92
|
-
*/
|
|
93
|
-
export class AnalyticsService extends BaseService {
|
|
94
|
-
private appointmentService: AppointmentService;
|
|
95
|
-
private reviewAnalyticsService: ReviewAnalyticsService;
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Creates a new AnalyticsService instance.
|
|
99
|
-
*
|
|
100
|
-
* @param db Firestore instance
|
|
101
|
-
* @param auth Firebase Auth instance
|
|
102
|
-
* @param app Firebase App instance
|
|
103
|
-
* @param appointmentService Appointment service instance for querying appointments
|
|
104
|
-
*/
|
|
105
|
-
constructor(db: Firestore, auth: Auth, app: FirebaseApp, appointmentService: AppointmentService) {
|
|
106
|
-
super(db, auth, app);
|
|
107
|
-
this.appointmentService = appointmentService;
|
|
108
|
-
this.reviewAnalyticsService = new ReviewAnalyticsService(db, auth, app, appointmentService);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Fetches appointments with optional filters
|
|
113
|
-
*
|
|
114
|
-
* @param filters - Optional filters
|
|
115
|
-
* @param dateRange - Optional date range
|
|
116
|
-
* @returns Array of appointments
|
|
117
|
-
*/
|
|
118
|
-
private async fetchAppointments(
|
|
119
|
-
filters?: AnalyticsFilters,
|
|
120
|
-
dateRange?: AnalyticsDateRange,
|
|
121
|
-
): Promise<Appointment[]> {
|
|
122
|
-
try {
|
|
123
|
-
// Build query constraints
|
|
124
|
-
const constraints: any[] = [];
|
|
125
|
-
|
|
126
|
-
if (filters?.clinicBranchId) {
|
|
127
|
-
constraints.push(where('clinicBranchId', '==', filters.clinicBranchId));
|
|
128
|
-
}
|
|
129
|
-
if (filters?.practitionerId) {
|
|
130
|
-
constraints.push(where('practitionerId', '==', filters.practitionerId));
|
|
131
|
-
}
|
|
132
|
-
if (filters?.procedureId) {
|
|
133
|
-
constraints.push(where('procedureId', '==', filters.procedureId));
|
|
134
|
-
}
|
|
135
|
-
if (filters?.patientId) {
|
|
136
|
-
constraints.push(where('patientId', '==', filters.patientId));
|
|
137
|
-
}
|
|
138
|
-
if (dateRange) {
|
|
139
|
-
constraints.push(where('appointmentStartTime', '>=', Timestamp.fromDate(dateRange.start)));
|
|
140
|
-
constraints.push(where('appointmentStartTime', '<=', Timestamp.fromDate(dateRange.end)));
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Use AppointmentService to search appointments
|
|
144
|
-
const searchParams: any = {};
|
|
145
|
-
if (filters?.clinicBranchId) searchParams.clinicBranchId = filters.clinicBranchId;
|
|
146
|
-
if (filters?.practitionerId) searchParams.practitionerId = filters.practitionerId;
|
|
147
|
-
if (filters?.procedureId) searchParams.procedureId = filters.procedureId;
|
|
148
|
-
if (filters?.patientId) searchParams.patientId = filters.patientId;
|
|
149
|
-
if (dateRange) {
|
|
150
|
-
searchParams.startDate = dateRange.start;
|
|
151
|
-
searchParams.endDate = dateRange.end;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const result = await this.appointmentService.searchAppointments(searchParams);
|
|
155
|
-
|
|
156
|
-
return result.appointments;
|
|
157
|
-
} catch (error) {
|
|
158
|
-
console.error('[AnalyticsService] Error fetching appointments:', error);
|
|
159
|
-
throw error;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// ==========================================
|
|
164
|
-
// Practitioner Analytics
|
|
165
|
-
// ==========================================
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Get practitioner performance metrics
|
|
169
|
-
* First checks for stored analytics, then calculates if not available or stale
|
|
170
|
-
*
|
|
171
|
-
* @param practitionerId - ID of the practitioner
|
|
172
|
-
* @param dateRange - Optional date range filter
|
|
173
|
-
* @param options - Options for reading stored analytics
|
|
174
|
-
* @returns Practitioner analytics object
|
|
175
|
-
*/
|
|
176
|
-
async getPractitionerAnalytics(
|
|
177
|
-
practitionerId: string,
|
|
178
|
-
dateRange?: AnalyticsDateRange,
|
|
179
|
-
options?: ReadStoredAnalyticsOptions,
|
|
180
|
-
): Promise<PractitionerAnalytics> {
|
|
181
|
-
// Try to read from stored analytics first
|
|
182
|
-
if (dateRange && options?.useCache !== false) {
|
|
183
|
-
// Determine period from date range
|
|
184
|
-
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
185
|
-
const clinicBranchId = options?.clinicBranchId; // Would need to be passed or determined
|
|
186
|
-
|
|
187
|
-
if (clinicBranchId) {
|
|
188
|
-
const stored = await readStoredPractitionerAnalytics(
|
|
189
|
-
this.db,
|
|
190
|
-
clinicBranchId,
|
|
191
|
-
practitionerId,
|
|
192
|
-
{ ...options, period },
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
if (stored) {
|
|
196
|
-
// Return stored data (without metadata)
|
|
197
|
-
const { metadata, ...analytics } = stored;
|
|
198
|
-
return analytics;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Fall back to calculation
|
|
204
|
-
const appointments = await this.fetchAppointments({ practitionerId }, dateRange);
|
|
205
|
-
|
|
206
|
-
const completed = getCompletedAppointments(appointments);
|
|
207
|
-
const canceled = getCanceledAppointments(appointments);
|
|
208
|
-
const noShow = getNoShowAppointments(appointments);
|
|
209
|
-
const pending = filterAppointments(appointments, { practitionerId }).filter(
|
|
210
|
-
a => a.status === AppointmentStatus.PENDING,
|
|
211
|
-
);
|
|
212
|
-
const confirmed = filterAppointments(appointments, { practitionerId }).filter(
|
|
213
|
-
a => a.status === AppointmentStatus.CONFIRMED,
|
|
214
|
-
);
|
|
215
|
-
|
|
216
|
-
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
217
|
-
const timeMetrics = calculateAverageTimeMetrics(completed);
|
|
218
|
-
|
|
219
|
-
// Get unique patients
|
|
220
|
-
const uniquePatients = new Set(appointments.map(a => a.patientId));
|
|
221
|
-
const returningPatients = new Set(
|
|
222
|
-
appointments
|
|
223
|
-
.filter(a => {
|
|
224
|
-
const patientAppointments = appointments.filter(ap => ap.patientId === a.patientId);
|
|
225
|
-
return patientAppointments.length > 1;
|
|
226
|
-
})
|
|
227
|
-
.map(a => a.patientId),
|
|
228
|
-
);
|
|
229
|
-
|
|
230
|
-
// Get top procedures
|
|
231
|
-
const procedureMap = new Map<string, { name: string; count: number; revenue: number }>();
|
|
232
|
-
completed.forEach(appointment => {
|
|
233
|
-
const procId = appointment.procedureId;
|
|
234
|
-
const procName = appointment.procedureInfo?.name || 'Unknown';
|
|
235
|
-
const cost = calculateAppointmentCost(appointment).cost;
|
|
236
|
-
|
|
237
|
-
if (procedureMap.has(procId)) {
|
|
238
|
-
const existing = procedureMap.get(procId)!;
|
|
239
|
-
existing.count++;
|
|
240
|
-
existing.revenue += cost;
|
|
241
|
-
} else {
|
|
242
|
-
procedureMap.set(procId, { name: procName, count: 1, revenue: cost });
|
|
243
|
-
}
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
const topProcedures = Array.from(procedureMap.entries())
|
|
247
|
-
.map(([procedureId, data]) => ({
|
|
248
|
-
procedureId,
|
|
249
|
-
procedureName: data.name,
|
|
250
|
-
count: data.count,
|
|
251
|
-
revenue: data.revenue,
|
|
252
|
-
}))
|
|
253
|
-
.sort((a, b) => b.count - a.count)
|
|
254
|
-
.slice(0, 10);
|
|
255
|
-
|
|
256
|
-
const practitionerName =
|
|
257
|
-
appointments.length > 0
|
|
258
|
-
? appointments[0].practitionerInfo?.name || 'Unknown'
|
|
259
|
-
: 'Unknown';
|
|
260
|
-
|
|
261
|
-
return {
|
|
262
|
-
total: appointments.length,
|
|
263
|
-
dateRange,
|
|
264
|
-
practitionerId,
|
|
265
|
-
practitionerName,
|
|
266
|
-
totalAppointments: appointments.length,
|
|
267
|
-
completedAppointments: completed.length,
|
|
268
|
-
canceledAppointments: canceled.length,
|
|
269
|
-
noShowAppointments: noShow.length,
|
|
270
|
-
pendingAppointments: pending.length,
|
|
271
|
-
confirmedAppointments: confirmed.length,
|
|
272
|
-
cancellationRate: calculatePercentage(canceled.length, appointments.length),
|
|
273
|
-
noShowRate: calculatePercentage(noShow.length, appointments.length),
|
|
274
|
-
averageBookedTime: timeMetrics.averageBookedDuration,
|
|
275
|
-
averageActualTime: timeMetrics.averageActualDuration,
|
|
276
|
-
timeEfficiency: timeMetrics.averageEfficiency,
|
|
277
|
-
totalRevenue,
|
|
278
|
-
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
279
|
-
currency,
|
|
280
|
-
topProcedures,
|
|
281
|
-
patientRetentionRate: calculatePercentage(returningPatients.size, uniquePatients.size),
|
|
282
|
-
uniquePatients: uniquePatients.size,
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// ==========================================
|
|
287
|
-
// Procedure Analytics
|
|
288
|
-
// ==========================================
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Get procedure performance metrics
|
|
292
|
-
* First checks for stored analytics, then calculates if not available or stale
|
|
293
|
-
*
|
|
294
|
-
* @param procedureId - ID of the procedure (optional, if not provided returns all)
|
|
295
|
-
* @param dateRange - Optional date range filter
|
|
296
|
-
* @param options - Options for reading stored analytics
|
|
297
|
-
* @returns Procedure analytics object or array
|
|
298
|
-
*/
|
|
299
|
-
async getProcedureAnalytics(
|
|
300
|
-
procedureId?: string,
|
|
301
|
-
dateRange?: AnalyticsDateRange,
|
|
302
|
-
options?: ReadStoredAnalyticsOptions,
|
|
303
|
-
): Promise<ProcedureAnalytics | ProcedureAnalytics[]> {
|
|
304
|
-
// Try to read from stored analytics first (only for single procedure)
|
|
305
|
-
if (procedureId && dateRange && options?.useCache !== false) {
|
|
306
|
-
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
307
|
-
const clinicBranchId = options?.clinicBranchId;
|
|
308
|
-
|
|
309
|
-
if (clinicBranchId) {
|
|
310
|
-
const stored = await readStoredProcedureAnalytics(
|
|
311
|
-
this.db,
|
|
312
|
-
clinicBranchId,
|
|
313
|
-
procedureId,
|
|
314
|
-
{ ...options, period },
|
|
315
|
-
);
|
|
316
|
-
|
|
317
|
-
if (stored) {
|
|
318
|
-
// Return stored data (without metadata)
|
|
319
|
-
const { metadata, ...analytics } = stored;
|
|
320
|
-
return analytics;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Fall back to calculation
|
|
326
|
-
const appointments = await this.fetchAppointments(procedureId ? { procedureId } : undefined, dateRange);
|
|
327
|
-
|
|
328
|
-
if (procedureId) {
|
|
329
|
-
return this.calculateProcedureAnalytics(appointments, procedureId);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// Group by procedure
|
|
333
|
-
const procedureMap = new Map<string, Appointment[]>();
|
|
334
|
-
appointments.forEach(appointment => {
|
|
335
|
-
const procId = appointment.procedureId;
|
|
336
|
-
if (!procedureMap.has(procId)) {
|
|
337
|
-
procedureMap.set(procId, []);
|
|
338
|
-
}
|
|
339
|
-
procedureMap.get(procId)!.push(appointment);
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
return Array.from(procedureMap.entries()).map(([procId, procAppointments]) =>
|
|
343
|
-
this.calculateProcedureAnalytics(procAppointments, procId),
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Calculate analytics for a specific procedure
|
|
349
|
-
*
|
|
350
|
-
* @param appointments - Appointments for the procedure
|
|
351
|
-
* @param procedureId - Procedure ID
|
|
352
|
-
* @returns Procedure analytics
|
|
353
|
-
*/
|
|
354
|
-
private calculateProcedureAnalytics(
|
|
355
|
-
appointments: Appointment[],
|
|
356
|
-
procedureId: string,
|
|
357
|
-
): ProcedureAnalytics {
|
|
358
|
-
const completed = getCompletedAppointments(appointments);
|
|
359
|
-
const canceled = getCanceledAppointments(appointments);
|
|
360
|
-
const noShow = getNoShowAppointments(appointments);
|
|
361
|
-
|
|
362
|
-
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
363
|
-
const timeMetrics = calculateAverageTimeMetrics(completed);
|
|
364
|
-
|
|
365
|
-
const firstAppointment = appointments[0];
|
|
366
|
-
const procedureInfo = firstAppointment?.procedureExtendedInfo || firstAppointment?.procedureInfo;
|
|
367
|
-
|
|
368
|
-
// Extract product usage
|
|
369
|
-
const productMap = new Map<
|
|
370
|
-
string,
|
|
371
|
-
{ name: string; brandName: string; quantity: number; revenue: number; usageCount: number }
|
|
372
|
-
>();
|
|
373
|
-
|
|
374
|
-
completed.forEach(appointment => {
|
|
375
|
-
const products = extractProductUsage(appointment);
|
|
376
|
-
products.forEach(product => {
|
|
377
|
-
if (productMap.has(product.productId)) {
|
|
378
|
-
const existing = productMap.get(product.productId)!;
|
|
379
|
-
existing.quantity += product.quantity;
|
|
380
|
-
existing.revenue += product.subtotal;
|
|
381
|
-
existing.usageCount++;
|
|
382
|
-
} else {
|
|
383
|
-
productMap.set(product.productId, {
|
|
384
|
-
name: product.productName,
|
|
385
|
-
brandName: product.brandName,
|
|
386
|
-
quantity: product.quantity,
|
|
387
|
-
revenue: product.subtotal,
|
|
388
|
-
usageCount: 1,
|
|
389
|
-
});
|
|
390
|
-
}
|
|
391
|
-
});
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
const productUsage = Array.from(productMap.entries()).map(([productId, data]) => ({
|
|
395
|
-
productId,
|
|
396
|
-
productName: data.name,
|
|
397
|
-
brandName: data.brandName,
|
|
398
|
-
totalQuantity: data.quantity,
|
|
399
|
-
totalRevenue: data.revenue,
|
|
400
|
-
usageCount: data.usageCount,
|
|
401
|
-
}));
|
|
402
|
-
|
|
403
|
-
return {
|
|
404
|
-
total: appointments.length,
|
|
405
|
-
procedureId,
|
|
406
|
-
procedureName: procedureInfo?.name || 'Unknown',
|
|
407
|
-
procedureFamily: procedureInfo?.procedureFamily || '',
|
|
408
|
-
categoryName: procedureInfo?.procedureCategoryName || '',
|
|
409
|
-
subcategoryName: procedureInfo?.procedureSubCategoryName || '',
|
|
410
|
-
technologyName: procedureInfo?.procedureTechnologyName || '',
|
|
411
|
-
totalAppointments: appointments.length,
|
|
412
|
-
completedAppointments: completed.length,
|
|
413
|
-
canceledAppointments: canceled.length,
|
|
414
|
-
noShowAppointments: noShow.length,
|
|
415
|
-
cancellationRate: calculatePercentage(canceled.length, appointments.length),
|
|
416
|
-
noShowRate: calculatePercentage(noShow.length, appointments.length),
|
|
417
|
-
averageCost: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
418
|
-
totalRevenue,
|
|
419
|
-
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
420
|
-
currency,
|
|
421
|
-
averageBookedDuration: timeMetrics.averageBookedDuration,
|
|
422
|
-
averageActualDuration: timeMetrics.averageActualDuration,
|
|
423
|
-
productUsage,
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* Get procedure popularity metrics
|
|
429
|
-
*
|
|
430
|
-
* @param dateRange - Optional date range filter
|
|
431
|
-
* @param limit - Number of top procedures to return
|
|
432
|
-
* @returns Array of procedure popularity metrics
|
|
433
|
-
*/
|
|
434
|
-
async getProcedurePopularity(
|
|
435
|
-
dateRange?: AnalyticsDateRange,
|
|
436
|
-
limit: number = 10,
|
|
437
|
-
): Promise<ProcedurePopularity[]> {
|
|
438
|
-
const appointments = await this.fetchAppointments(undefined, dateRange);
|
|
439
|
-
const completed = getCompletedAppointments(appointments);
|
|
440
|
-
|
|
441
|
-
const procedureMap = new Map<string, { name: string; category: string; subcategory: string; technology: string; count: number }>();
|
|
442
|
-
|
|
443
|
-
completed.forEach(appointment => {
|
|
444
|
-
const procId = appointment.procedureId;
|
|
445
|
-
const procInfo = appointment.procedureExtendedInfo || appointment.procedureInfo;
|
|
446
|
-
|
|
447
|
-
if (procedureMap.has(procId)) {
|
|
448
|
-
procedureMap.get(procId)!.count++;
|
|
449
|
-
} else {
|
|
450
|
-
procedureMap.set(procId, {
|
|
451
|
-
name: procInfo?.name || 'Unknown',
|
|
452
|
-
category: procInfo?.procedureCategoryName || '',
|
|
453
|
-
subcategory: procInfo?.procedureSubCategoryName || '',
|
|
454
|
-
technology: procInfo?.procedureTechnologyName || '',
|
|
455
|
-
count: 1,
|
|
456
|
-
});
|
|
457
|
-
}
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
return Array.from(procedureMap.entries())
|
|
461
|
-
.map(([procedureId, data]) => ({
|
|
462
|
-
procedureId,
|
|
463
|
-
procedureName: data.name,
|
|
464
|
-
categoryName: data.category,
|
|
465
|
-
subcategoryName: data.subcategory,
|
|
466
|
-
technologyName: data.technology,
|
|
467
|
-
appointmentCount: data.count,
|
|
468
|
-
completedCount: data.count,
|
|
469
|
-
rank: 0, // Will be set after sorting
|
|
470
|
-
}))
|
|
471
|
-
.sort((a, b) => b.appointmentCount - a.appointmentCount)
|
|
472
|
-
.slice(0, limit)
|
|
473
|
-
.map((item, index) => ({ ...item, rank: index + 1 }));
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* Get procedure profitability metrics
|
|
478
|
-
*
|
|
479
|
-
* @param dateRange - Optional date range filter
|
|
480
|
-
* @param limit - Number of top procedures to return
|
|
481
|
-
* @returns Array of procedure profitability metrics
|
|
482
|
-
*/
|
|
483
|
-
async getProcedureProfitability(
|
|
484
|
-
dateRange?: AnalyticsDateRange,
|
|
485
|
-
limit: number = 10,
|
|
486
|
-
): Promise<ProcedureProfitability[]> {
|
|
487
|
-
const appointments = await this.fetchAppointments(undefined, dateRange);
|
|
488
|
-
const completed = getCompletedAppointments(appointments);
|
|
489
|
-
|
|
490
|
-
const procedureMap = new Map<
|
|
491
|
-
string,
|
|
492
|
-
{
|
|
493
|
-
name: string;
|
|
494
|
-
category: string;
|
|
495
|
-
subcategory: string;
|
|
496
|
-
technology: string;
|
|
497
|
-
revenue: number;
|
|
498
|
-
count: number;
|
|
499
|
-
}
|
|
500
|
-
>();
|
|
501
|
-
|
|
502
|
-
completed.forEach(appointment => {
|
|
503
|
-
const procId = appointment.procedureId;
|
|
504
|
-
const procInfo = appointment.procedureExtendedInfo || appointment.procedureInfo;
|
|
505
|
-
const cost = calculateAppointmentCost(appointment).cost;
|
|
506
|
-
|
|
507
|
-
if (procedureMap.has(procId)) {
|
|
508
|
-
const existing = procedureMap.get(procId)!;
|
|
509
|
-
existing.revenue += cost;
|
|
510
|
-
existing.count++;
|
|
511
|
-
} else {
|
|
512
|
-
procedureMap.set(procId, {
|
|
513
|
-
name: procInfo?.name || 'Unknown',
|
|
514
|
-
category: procInfo?.procedureCategoryName || '',
|
|
515
|
-
subcategory: procInfo?.procedureSubCategoryName || '',
|
|
516
|
-
technology: procInfo?.procedureTechnologyName || '',
|
|
517
|
-
revenue: cost,
|
|
518
|
-
count: 1,
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
});
|
|
522
|
-
|
|
523
|
-
return Array.from(procedureMap.entries())
|
|
524
|
-
.map(([procedureId, data]) => ({
|
|
525
|
-
procedureId,
|
|
526
|
-
procedureName: data.name,
|
|
527
|
-
categoryName: data.category,
|
|
528
|
-
subcategoryName: data.subcategory,
|
|
529
|
-
technologyName: data.technology,
|
|
530
|
-
totalRevenue: data.revenue,
|
|
531
|
-
averageRevenue: data.count > 0 ? data.revenue / data.count : 0,
|
|
532
|
-
appointmentCount: data.count,
|
|
533
|
-
rank: 0, // Will be set after sorting
|
|
534
|
-
}))
|
|
535
|
-
.sort((a, b) => b.totalRevenue - a.totalRevenue)
|
|
536
|
-
.slice(0, limit)
|
|
537
|
-
.map((item, index) => ({ ...item, rank: index + 1 }));
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
// ==========================================
|
|
541
|
-
// Time Efficiency Analytics
|
|
542
|
-
// ==========================================
|
|
543
|
-
|
|
544
|
-
/**
|
|
545
|
-
* Get time efficiency metrics grouped by clinic, practitioner, procedure, patient, or technology
|
|
546
|
-
*
|
|
547
|
-
* @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient' | 'technology'
|
|
548
|
-
* @param dateRange - Optional date range filter
|
|
549
|
-
* @param filters - Optional additional filters
|
|
550
|
-
* @returns Grouped time efficiency metrics
|
|
551
|
-
*/
|
|
552
|
-
async getTimeEfficiencyMetricsByEntity(
|
|
553
|
-
groupBy: EntityType,
|
|
554
|
-
dateRange?: AnalyticsDateRange,
|
|
555
|
-
filters?: AnalyticsFilters,
|
|
556
|
-
): Promise<GroupedTimeEfficiencyMetrics[]> {
|
|
557
|
-
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
558
|
-
return calculateGroupedTimeEfficiencyMetrics(appointments, groupBy);
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
/**
|
|
562
|
-
* Get time efficiency metrics for appointments
|
|
563
|
-
* First checks for stored analytics, then calculates if not available or stale
|
|
564
|
-
*
|
|
565
|
-
* @param filters - Optional filters
|
|
566
|
-
* @param dateRange - Optional date range filter
|
|
567
|
-
* @param options - Options for reading stored analytics
|
|
568
|
-
* @returns Time efficiency metrics
|
|
569
|
-
*/
|
|
570
|
-
async getTimeEfficiencyMetrics(
|
|
571
|
-
filters?: AnalyticsFilters,
|
|
572
|
-
dateRange?: AnalyticsDateRange,
|
|
573
|
-
options?: ReadStoredAnalyticsOptions,
|
|
574
|
-
): Promise<TimeEfficiencyMetrics> {
|
|
575
|
-
// Try to read from stored analytics first
|
|
576
|
-
if (filters?.clinicBranchId && dateRange && options?.useCache !== false) {
|
|
577
|
-
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
578
|
-
const stored = await readStoredTimeEfficiencyMetrics(
|
|
579
|
-
this.db,
|
|
580
|
-
filters.clinicBranchId,
|
|
581
|
-
{ ...options, period },
|
|
582
|
-
);
|
|
583
|
-
|
|
584
|
-
if (stored) {
|
|
585
|
-
// Return stored data (without metadata)
|
|
586
|
-
const { metadata, ...metrics } = stored;
|
|
587
|
-
return metrics;
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
// Fall back to calculation
|
|
592
|
-
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
593
|
-
const completed = getCompletedAppointments(appointments);
|
|
594
|
-
|
|
595
|
-
const timeMetrics = calculateAverageTimeMetrics(completed);
|
|
596
|
-
const efficiencyDistribution = calculateEfficiencyDistribution(completed);
|
|
597
|
-
|
|
598
|
-
return {
|
|
599
|
-
totalAppointments: completed.length,
|
|
600
|
-
appointmentsWithActualTime: timeMetrics.appointmentsWithActualTime,
|
|
601
|
-
averageBookedDuration: timeMetrics.averageBookedDuration,
|
|
602
|
-
averageActualDuration: timeMetrics.averageActualDuration,
|
|
603
|
-
averageEfficiency: timeMetrics.averageEfficiency,
|
|
604
|
-
totalOverrun: timeMetrics.totalOverrun,
|
|
605
|
-
totalUnderutilization: timeMetrics.totalUnderutilization,
|
|
606
|
-
averageOverrun: timeMetrics.averageOverrun,
|
|
607
|
-
averageUnderutilization: timeMetrics.averageUnderutilization,
|
|
608
|
-
efficiencyDistribution,
|
|
609
|
-
};
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
// ==========================================
|
|
613
|
-
// Cancellation & No-Show Analytics
|
|
614
|
-
// ==========================================
|
|
615
|
-
|
|
616
|
-
/**
|
|
617
|
-
* Get cancellation metrics
|
|
618
|
-
* First checks for stored analytics when grouping by clinic, then calculates if not available or stale
|
|
619
|
-
*
|
|
620
|
-
* @param groupBy - Group by 'clinic' | 'practitioner' | 'patient' | 'procedure' | 'technology'
|
|
621
|
-
* @param dateRange - Optional date range filter
|
|
622
|
-
* @param options - Options for reading stored analytics (requires clinicBranchId for cache)
|
|
623
|
-
* @returns Cancellation metrics grouped by specified entity
|
|
624
|
-
*/
|
|
625
|
-
async getCancellationMetrics(
|
|
626
|
-
groupBy: EntityType,
|
|
627
|
-
dateRange?: AnalyticsDateRange,
|
|
628
|
-
options?: ReadStoredAnalyticsOptions,
|
|
629
|
-
): Promise<CancellationMetrics | CancellationMetrics[]> {
|
|
630
|
-
// Try to read from stored analytics first (only for clinic-level grouping)
|
|
631
|
-
if (groupBy === 'clinic' && dateRange && options?.useCache !== false && options?.clinicBranchId) {
|
|
632
|
-
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
633
|
-
const stored = await readStoredCancellationMetrics(
|
|
634
|
-
this.db,
|
|
635
|
-
options.clinicBranchId,
|
|
636
|
-
'clinic',
|
|
637
|
-
{ ...options, period },
|
|
638
|
-
);
|
|
639
|
-
|
|
640
|
-
if (stored) {
|
|
641
|
-
// Return stored data (without metadata)
|
|
642
|
-
const { metadata, ...metrics } = stored;
|
|
643
|
-
return metrics;
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// Fall back to calculation
|
|
648
|
-
const appointments = await this.fetchAppointments(undefined, dateRange);
|
|
649
|
-
const canceled = getCanceledAppointments(appointments);
|
|
650
|
-
|
|
651
|
-
if (groupBy === 'clinic') {
|
|
652
|
-
return this.groupCancellationsByClinic(canceled, appointments);
|
|
653
|
-
} else if (groupBy === 'practitioner') {
|
|
654
|
-
return this.groupCancellationsByPractitioner(canceled, appointments);
|
|
655
|
-
} else if (groupBy === 'patient') {
|
|
656
|
-
return this.groupCancellationsByPatient(canceled, appointments);
|
|
657
|
-
} else if (groupBy === 'technology') {
|
|
658
|
-
return this.groupCancellationsByTechnology(canceled, appointments);
|
|
659
|
-
} else {
|
|
660
|
-
return this.groupCancellationsByProcedure(canceled, appointments);
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
/**
|
|
665
|
-
* Group cancellations by clinic
|
|
666
|
-
*/
|
|
667
|
-
private groupCancellationsByClinic(
|
|
668
|
-
canceled: Appointment[],
|
|
669
|
-
allAppointments: Appointment[],
|
|
670
|
-
): CancellationMetrics[] {
|
|
671
|
-
const clinicMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[] }>();
|
|
672
|
-
|
|
673
|
-
allAppointments.forEach(appointment => {
|
|
674
|
-
const clinicId = appointment.clinicBranchId;
|
|
675
|
-
const clinicName = appointment.clinicInfo?.name || 'Unknown';
|
|
676
|
-
|
|
677
|
-
if (!clinicMap.has(clinicId)) {
|
|
678
|
-
clinicMap.set(clinicId, { name: clinicName, canceled: [], all: [] });
|
|
679
|
-
}
|
|
680
|
-
clinicMap.get(clinicId)!.all.push(appointment);
|
|
681
|
-
});
|
|
682
|
-
|
|
683
|
-
canceled.forEach(appointment => {
|
|
684
|
-
const clinicId = appointment.clinicBranchId;
|
|
685
|
-
if (clinicMap.has(clinicId)) {
|
|
686
|
-
clinicMap.get(clinicId)!.canceled.push(appointment);
|
|
687
|
-
}
|
|
688
|
-
});
|
|
689
|
-
|
|
690
|
-
return Array.from(clinicMap.entries()).map(([clinicId, data]) =>
|
|
691
|
-
this.calculateCancellationMetrics(clinicId, data.name, 'clinic', data.canceled, data.all),
|
|
692
|
-
);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
/**
|
|
696
|
-
* Group cancellations by practitioner
|
|
697
|
-
*/
|
|
698
|
-
private groupCancellationsByPractitioner(
|
|
699
|
-
canceled: Appointment[],
|
|
700
|
-
allAppointments: Appointment[],
|
|
701
|
-
): CancellationMetrics[] {
|
|
702
|
-
const practitionerMap = new Map<
|
|
703
|
-
string,
|
|
704
|
-
{ name: string; canceled: Appointment[]; all: Appointment[] }
|
|
705
|
-
>();
|
|
706
|
-
|
|
707
|
-
allAppointments.forEach(appointment => {
|
|
708
|
-
const practitionerId = appointment.practitionerId;
|
|
709
|
-
const practitionerName = appointment.practitionerInfo?.name || 'Unknown';
|
|
710
|
-
|
|
711
|
-
if (!practitionerMap.has(practitionerId)) {
|
|
712
|
-
practitionerMap.set(practitionerId, { name: practitionerName, canceled: [], all: [] });
|
|
713
|
-
}
|
|
714
|
-
practitionerMap.get(practitionerId)!.all.push(appointment);
|
|
715
|
-
});
|
|
716
|
-
|
|
717
|
-
canceled.forEach(appointment => {
|
|
718
|
-
const practitionerId = appointment.practitionerId;
|
|
719
|
-
if (practitionerMap.has(practitionerId)) {
|
|
720
|
-
practitionerMap.get(practitionerId)!.canceled.push(appointment);
|
|
721
|
-
}
|
|
722
|
-
});
|
|
723
|
-
|
|
724
|
-
return Array.from(practitionerMap.entries()).map(([practitionerId, data]) =>
|
|
725
|
-
this.calculateCancellationMetrics(
|
|
726
|
-
practitionerId,
|
|
727
|
-
data.name,
|
|
728
|
-
'practitioner',
|
|
729
|
-
data.canceled,
|
|
730
|
-
data.all,
|
|
731
|
-
),
|
|
732
|
-
);
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
/**
|
|
736
|
-
* Group cancellations by patient
|
|
737
|
-
*/
|
|
738
|
-
private groupCancellationsByPatient(
|
|
739
|
-
canceled: Appointment[],
|
|
740
|
-
allAppointments: Appointment[],
|
|
741
|
-
): CancellationMetrics[] {
|
|
742
|
-
const patientMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[] }>();
|
|
743
|
-
|
|
744
|
-
allAppointments.forEach(appointment => {
|
|
745
|
-
const patientId = appointment.patientId;
|
|
746
|
-
const patientName = appointment.patientInfo?.fullName || 'Unknown';
|
|
747
|
-
|
|
748
|
-
if (!patientMap.has(patientId)) {
|
|
749
|
-
patientMap.set(patientId, { name: patientName, canceled: [], all: [] });
|
|
750
|
-
}
|
|
751
|
-
patientMap.get(patientId)!.all.push(appointment);
|
|
752
|
-
});
|
|
753
|
-
|
|
754
|
-
canceled.forEach(appointment => {
|
|
755
|
-
const patientId = appointment.patientId;
|
|
756
|
-
if (patientMap.has(patientId)) {
|
|
757
|
-
patientMap.get(patientId)!.canceled.push(appointment);
|
|
758
|
-
}
|
|
759
|
-
});
|
|
760
|
-
|
|
761
|
-
return Array.from(patientMap.entries()).map(([patientId, data]) =>
|
|
762
|
-
this.calculateCancellationMetrics(patientId, data.name, 'patient', data.canceled, data.all),
|
|
763
|
-
);
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
/**
|
|
767
|
-
* Group cancellations by procedure
|
|
768
|
-
*/
|
|
769
|
-
private groupCancellationsByProcedure(
|
|
770
|
-
canceled: Appointment[],
|
|
771
|
-
allAppointments: Appointment[],
|
|
772
|
-
): CancellationMetrics[] {
|
|
773
|
-
const procedureMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[]; practitionerId?: string; practitionerName?: string }>();
|
|
774
|
-
|
|
775
|
-
allAppointments.forEach(appointment => {
|
|
776
|
-
const procedureId = appointment.procedureId;
|
|
777
|
-
const procedureName = appointment.procedureInfo?.name || 'Unknown';
|
|
778
|
-
|
|
779
|
-
if (!procedureMap.has(procedureId)) {
|
|
780
|
-
procedureMap.set(procedureId, {
|
|
781
|
-
name: procedureName,
|
|
782
|
-
canceled: [],
|
|
783
|
-
all: [],
|
|
784
|
-
practitionerId: appointment.practitionerId,
|
|
785
|
-
practitionerName: appointment.practitionerInfo?.name,
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
procedureMap.get(procedureId)!.all.push(appointment);
|
|
789
|
-
});
|
|
790
|
-
|
|
791
|
-
canceled.forEach(appointment => {
|
|
792
|
-
const procedureId = appointment.procedureId;
|
|
793
|
-
if (procedureMap.has(procedureId)) {
|
|
794
|
-
procedureMap.get(procedureId)!.canceled.push(appointment);
|
|
795
|
-
}
|
|
796
|
-
});
|
|
797
|
-
|
|
798
|
-
return Array.from(procedureMap.entries()).map(([procedureId, data]) => {
|
|
799
|
-
const metrics = this.calculateCancellationMetrics(
|
|
800
|
-
procedureId,
|
|
801
|
-
data.name,
|
|
802
|
-
'procedure',
|
|
803
|
-
data.canceled,
|
|
804
|
-
data.all,
|
|
805
|
-
);
|
|
806
|
-
return {
|
|
807
|
-
...metrics,
|
|
808
|
-
...(data.practitionerId && { practitionerId: data.practitionerId }),
|
|
809
|
-
...(data.practitionerName && { practitionerName: data.practitionerName }),
|
|
810
|
-
};
|
|
811
|
-
});
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
/**
|
|
815
|
-
* Group cancellations by technology
|
|
816
|
-
* Aggregates all procedures using the same technology across all doctors
|
|
817
|
-
*/
|
|
818
|
-
private groupCancellationsByTechnology(
|
|
819
|
-
canceled: Appointment[],
|
|
820
|
-
allAppointments: Appointment[],
|
|
821
|
-
): CancellationMetrics[] {
|
|
822
|
-
const technologyMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[] }>();
|
|
823
|
-
|
|
824
|
-
allAppointments.forEach(appointment => {
|
|
825
|
-
const technologyId =
|
|
826
|
-
appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
|
|
827
|
-
const technologyName =
|
|
828
|
-
appointment.procedureExtendedInfo?.procedureTechnologyName ||
|
|
829
|
-
appointment.procedureInfo?.technologyName ||
|
|
830
|
-
'Unknown';
|
|
831
|
-
|
|
832
|
-
if (!technologyMap.has(technologyId)) {
|
|
833
|
-
technologyMap.set(technologyId, { name: technologyName, canceled: [], all: [] });
|
|
834
|
-
}
|
|
835
|
-
technologyMap.get(technologyId)!.all.push(appointment);
|
|
836
|
-
});
|
|
837
|
-
|
|
838
|
-
canceled.forEach(appointment => {
|
|
839
|
-
const technologyId =
|
|
840
|
-
appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
|
|
841
|
-
if (technologyMap.has(technologyId)) {
|
|
842
|
-
technologyMap.get(technologyId)!.canceled.push(appointment);
|
|
843
|
-
}
|
|
844
|
-
});
|
|
845
|
-
|
|
846
|
-
return Array.from(technologyMap.entries()).map(([technologyId, data]) =>
|
|
847
|
-
this.calculateCancellationMetrics(
|
|
848
|
-
technologyId,
|
|
849
|
-
data.name,
|
|
850
|
-
'technology',
|
|
851
|
-
data.canceled,
|
|
852
|
-
data.all,
|
|
853
|
-
),
|
|
854
|
-
);
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
/**
|
|
858
|
-
* Calculate cancellation metrics for a specific entity
|
|
859
|
-
*/
|
|
860
|
-
private calculateCancellationMetrics(
|
|
861
|
-
entityId: string,
|
|
862
|
-
entityName: string,
|
|
863
|
-
entityType: EntityType,
|
|
864
|
-
canceled: Appointment[],
|
|
865
|
-
all: Appointment[],
|
|
866
|
-
): CancellationMetrics {
|
|
867
|
-
const canceledByPatient = canceled.filter(
|
|
868
|
-
a => a.status === AppointmentStatus.CANCELED_PATIENT,
|
|
869
|
-
).length;
|
|
870
|
-
const canceledByClinic = canceled.filter(
|
|
871
|
-
a => a.status === AppointmentStatus.CANCELED_CLINIC,
|
|
872
|
-
).length;
|
|
873
|
-
const canceledRescheduled = canceled.filter(
|
|
874
|
-
a => a.status === AppointmentStatus.CANCELED_PATIENT_RESCHEDULED,
|
|
875
|
-
).length;
|
|
876
|
-
|
|
877
|
-
// Calculate average cancellation lead time
|
|
878
|
-
const leadTimes = canceled
|
|
879
|
-
.map(a => calculateCancellationLeadTime(a))
|
|
880
|
-
.filter((lt): lt is number => lt !== null);
|
|
881
|
-
const averageLeadTime =
|
|
882
|
-
leadTimes.length > 0 ? leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length : 0;
|
|
883
|
-
|
|
884
|
-
// Group cancellation reasons
|
|
885
|
-
const reasonMap = new Map<string, number>();
|
|
886
|
-
canceled.forEach(appointment => {
|
|
887
|
-
const reason = appointment.cancellationReason || 'No reason provided';
|
|
888
|
-
reasonMap.set(reason, (reasonMap.get(reason) || 0) + 1);
|
|
889
|
-
});
|
|
890
|
-
|
|
891
|
-
const cancellationReasons = Array.from(reasonMap.entries()).map(([reason, count]) => ({
|
|
892
|
-
reason,
|
|
893
|
-
count,
|
|
894
|
-
percentage: calculatePercentage(count, canceled.length),
|
|
895
|
-
}));
|
|
896
|
-
|
|
897
|
-
return {
|
|
898
|
-
entityId,
|
|
899
|
-
entityName,
|
|
900
|
-
entityType,
|
|
901
|
-
totalAppointments: all.length,
|
|
902
|
-
canceledAppointments: canceled.length,
|
|
903
|
-
cancellationRate: calculatePercentage(canceled.length, all.length),
|
|
904
|
-
canceledByPatient,
|
|
905
|
-
canceledByClinic,
|
|
906
|
-
canceledByPractitioner: 0, // Not tracked in current status enum
|
|
907
|
-
canceledRescheduled,
|
|
908
|
-
averageCancellationLeadTime: Math.round(averageLeadTime * 100) / 100,
|
|
909
|
-
cancellationReasons,
|
|
910
|
-
};
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
/**
|
|
914
|
-
* Get no-show metrics
|
|
915
|
-
* First checks for stored analytics when grouping by clinic, then calculates if not available or stale
|
|
916
|
-
*
|
|
917
|
-
* @param groupBy - Group by 'clinic' | 'practitioner' | 'patient' | 'procedure' | 'technology'
|
|
918
|
-
* @param dateRange - Optional date range filter
|
|
919
|
-
* @param options - Options for reading stored analytics (requires clinicBranchId for cache)
|
|
920
|
-
* @returns No-show metrics grouped by specified entity
|
|
921
|
-
*/
|
|
922
|
-
async getNoShowMetrics(
|
|
923
|
-
groupBy: EntityType,
|
|
924
|
-
dateRange?: AnalyticsDateRange,
|
|
925
|
-
options?: ReadStoredAnalyticsOptions,
|
|
926
|
-
): Promise<NoShowMetrics | NoShowMetrics[]> {
|
|
927
|
-
// Try to read from stored analytics first (only for clinic-level grouping)
|
|
928
|
-
if (groupBy === 'clinic' && dateRange && options?.useCache !== false && options?.clinicBranchId) {
|
|
929
|
-
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
930
|
-
const stored = await readStoredNoShowMetrics(
|
|
931
|
-
this.db,
|
|
932
|
-
options.clinicBranchId,
|
|
933
|
-
'clinic',
|
|
934
|
-
{ ...options, period },
|
|
935
|
-
);
|
|
936
|
-
|
|
937
|
-
if (stored) {
|
|
938
|
-
// Return stored data (without metadata)
|
|
939
|
-
const { metadata, ...metrics } = stored;
|
|
940
|
-
return metrics;
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
// Fall back to calculation
|
|
945
|
-
const appointments = await this.fetchAppointments(undefined, dateRange);
|
|
946
|
-
const noShow = getNoShowAppointments(appointments);
|
|
947
|
-
|
|
948
|
-
if (groupBy === 'clinic') {
|
|
949
|
-
return this.groupNoShowsByClinic(noShow, appointments);
|
|
950
|
-
} else if (groupBy === 'practitioner') {
|
|
951
|
-
return this.groupNoShowsByPractitioner(noShow, appointments);
|
|
952
|
-
} else if (groupBy === 'patient') {
|
|
953
|
-
return this.groupNoShowsByPatient(noShow, appointments);
|
|
954
|
-
} else if (groupBy === 'technology') {
|
|
955
|
-
return this.groupNoShowsByTechnology(noShow, appointments);
|
|
956
|
-
} else {
|
|
957
|
-
return this.groupNoShowsByProcedure(noShow, appointments);
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
/**
|
|
962
|
-
* Group no-shows by clinic
|
|
963
|
-
*/
|
|
964
|
-
private groupNoShowsByClinic(
|
|
965
|
-
noShow: Appointment[],
|
|
966
|
-
allAppointments: Appointment[],
|
|
967
|
-
): NoShowMetrics[] {
|
|
968
|
-
const clinicMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[] }>();
|
|
969
|
-
|
|
970
|
-
allAppointments.forEach(appointment => {
|
|
971
|
-
const clinicId = appointment.clinicBranchId;
|
|
972
|
-
const clinicName = appointment.clinicInfo?.name || 'Unknown';
|
|
973
|
-
if (!clinicMap.has(clinicId)) {
|
|
974
|
-
clinicMap.set(clinicId, { name: clinicName, noShow: [], all: [] });
|
|
975
|
-
}
|
|
976
|
-
clinicMap.get(clinicId)!.all.push(appointment);
|
|
977
|
-
});
|
|
978
|
-
|
|
979
|
-
noShow.forEach(appointment => {
|
|
980
|
-
const clinicId = appointment.clinicBranchId;
|
|
981
|
-
if (clinicMap.has(clinicId)) {
|
|
982
|
-
clinicMap.get(clinicId)!.noShow.push(appointment);
|
|
983
|
-
}
|
|
984
|
-
});
|
|
985
|
-
|
|
986
|
-
return Array.from(clinicMap.entries()).map(([clinicId, data]) => ({
|
|
987
|
-
entityId: clinicId,
|
|
988
|
-
entityName: data.name,
|
|
989
|
-
entityType: 'clinic' as EntityType,
|
|
990
|
-
totalAppointments: data.all.length,
|
|
991
|
-
noShowAppointments: data.noShow.length,
|
|
992
|
-
noShowRate: calculatePercentage(data.noShow.length, data.all.length),
|
|
993
|
-
}));
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
/**
|
|
997
|
-
* Group no-shows by practitioner
|
|
998
|
-
*/
|
|
999
|
-
private groupNoShowsByPractitioner(
|
|
1000
|
-
noShow: Appointment[],
|
|
1001
|
-
allAppointments: Appointment[],
|
|
1002
|
-
): NoShowMetrics[] {
|
|
1003
|
-
const practitionerMap = new Map<
|
|
1004
|
-
string,
|
|
1005
|
-
{ name: string; noShow: Appointment[]; all: Appointment[] }
|
|
1006
|
-
>();
|
|
1007
|
-
|
|
1008
|
-
allAppointments.forEach(appointment => {
|
|
1009
|
-
const practitionerId = appointment.practitionerId;
|
|
1010
|
-
const practitionerName = appointment.practitionerInfo?.name || 'Unknown';
|
|
1011
|
-
|
|
1012
|
-
if (!practitionerMap.has(practitionerId)) {
|
|
1013
|
-
practitionerMap.set(practitionerId, { name: practitionerName, noShow: [], all: [] });
|
|
1014
|
-
}
|
|
1015
|
-
practitionerMap.get(practitionerId)!.all.push(appointment);
|
|
1016
|
-
});
|
|
1017
|
-
|
|
1018
|
-
noShow.forEach(appointment => {
|
|
1019
|
-
const practitionerId = appointment.practitionerId;
|
|
1020
|
-
if (practitionerMap.has(practitionerId)) {
|
|
1021
|
-
practitionerMap.get(practitionerId)!.noShow.push(appointment);
|
|
1022
|
-
}
|
|
1023
|
-
});
|
|
1024
|
-
|
|
1025
|
-
return Array.from(practitionerMap.entries()).map(([practitionerId, data]) => ({
|
|
1026
|
-
entityId: practitionerId,
|
|
1027
|
-
entityName: data.name,
|
|
1028
|
-
entityType: 'practitioner' as EntityType,
|
|
1029
|
-
totalAppointments: data.all.length,
|
|
1030
|
-
noShowAppointments: data.noShow.length,
|
|
1031
|
-
noShowRate: calculatePercentage(data.noShow.length, data.all.length),
|
|
1032
|
-
}));
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
/**
|
|
1036
|
-
* Group no-shows by patient
|
|
1037
|
-
*/
|
|
1038
|
-
private groupNoShowsByPatient(
|
|
1039
|
-
noShow: Appointment[],
|
|
1040
|
-
allAppointments: Appointment[],
|
|
1041
|
-
): NoShowMetrics[] {
|
|
1042
|
-
const patientMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[] }>();
|
|
1043
|
-
|
|
1044
|
-
allAppointments.forEach(appointment => {
|
|
1045
|
-
const patientId = appointment.patientId;
|
|
1046
|
-
const patientName = appointment.patientInfo?.fullName || 'Unknown';
|
|
1047
|
-
|
|
1048
|
-
if (!patientMap.has(patientId)) {
|
|
1049
|
-
patientMap.set(patientId, { name: patientName, noShow: [], all: [] });
|
|
1050
|
-
}
|
|
1051
|
-
patientMap.get(patientId)!.all.push(appointment);
|
|
1052
|
-
});
|
|
1053
|
-
|
|
1054
|
-
noShow.forEach(appointment => {
|
|
1055
|
-
const patientId = appointment.patientId;
|
|
1056
|
-
if (patientMap.has(patientId)) {
|
|
1057
|
-
patientMap.get(patientId)!.noShow.push(appointment);
|
|
1058
|
-
}
|
|
1059
|
-
});
|
|
1060
|
-
|
|
1061
|
-
return Array.from(patientMap.entries()).map(([patientId, data]) => ({
|
|
1062
|
-
entityId: patientId,
|
|
1063
|
-
entityName: data.name,
|
|
1064
|
-
entityType: 'patient' as EntityType,
|
|
1065
|
-
totalAppointments: data.all.length,
|
|
1066
|
-
noShowAppointments: data.noShow.length,
|
|
1067
|
-
noShowRate: calculatePercentage(data.noShow.length, data.all.length),
|
|
1068
|
-
}));
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
/**
|
|
1072
|
-
* Group no-shows by procedure
|
|
1073
|
-
*/
|
|
1074
|
-
private groupNoShowsByProcedure(
|
|
1075
|
-
noShow: Appointment[],
|
|
1076
|
-
allAppointments: Appointment[],
|
|
1077
|
-
): NoShowMetrics[] {
|
|
1078
|
-
const procedureMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[]; practitionerId?: string; practitionerName?: string }>();
|
|
1079
|
-
|
|
1080
|
-
allAppointments.forEach(appointment => {
|
|
1081
|
-
const procedureId = appointment.procedureId;
|
|
1082
|
-
const procedureName = appointment.procedureInfo?.name || 'Unknown';
|
|
1083
|
-
|
|
1084
|
-
if (!procedureMap.has(procedureId)) {
|
|
1085
|
-
procedureMap.set(procedureId, {
|
|
1086
|
-
name: procedureName,
|
|
1087
|
-
noShow: [],
|
|
1088
|
-
all: [],
|
|
1089
|
-
practitionerId: appointment.practitionerId,
|
|
1090
|
-
practitionerName: appointment.practitionerInfo?.name,
|
|
1091
|
-
});
|
|
1092
|
-
}
|
|
1093
|
-
procedureMap.get(procedureId)!.all.push(appointment);
|
|
1094
|
-
});
|
|
1095
|
-
|
|
1096
|
-
noShow.forEach(appointment => {
|
|
1097
|
-
const procedureId = appointment.procedureId;
|
|
1098
|
-
if (procedureMap.has(procedureId)) {
|
|
1099
|
-
procedureMap.get(procedureId)!.noShow.push(appointment);
|
|
1100
|
-
}
|
|
1101
|
-
});
|
|
1102
|
-
|
|
1103
|
-
return Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
|
|
1104
|
-
entityId: procedureId,
|
|
1105
|
-
entityName: data.name,
|
|
1106
|
-
entityType: 'procedure' as EntityType,
|
|
1107
|
-
totalAppointments: data.all.length,
|
|
1108
|
-
noShowAppointments: data.noShow.length,
|
|
1109
|
-
noShowRate: calculatePercentage(data.noShow.length, data.all.length),
|
|
1110
|
-
...(data.practitionerId && { practitionerId: data.practitionerId }),
|
|
1111
|
-
...(data.practitionerName && { practitionerName: data.practitionerName }),
|
|
1112
|
-
}));
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
/**
|
|
1116
|
-
* Group no-shows by technology
|
|
1117
|
-
* Aggregates all procedures using the same technology across all doctors
|
|
1118
|
-
*/
|
|
1119
|
-
private groupNoShowsByTechnology(
|
|
1120
|
-
noShow: Appointment[],
|
|
1121
|
-
allAppointments: Appointment[],
|
|
1122
|
-
): NoShowMetrics[] {
|
|
1123
|
-
const technologyMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[] }>();
|
|
1124
|
-
|
|
1125
|
-
allAppointments.forEach(appointment => {
|
|
1126
|
-
const technologyId =
|
|
1127
|
-
appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
|
|
1128
|
-
const technologyName =
|
|
1129
|
-
appointment.procedureExtendedInfo?.procedureTechnologyName ||
|
|
1130
|
-
appointment.procedureInfo?.technologyName ||
|
|
1131
|
-
'Unknown';
|
|
1132
|
-
|
|
1133
|
-
if (!technologyMap.has(technologyId)) {
|
|
1134
|
-
technologyMap.set(technologyId, { name: technologyName, noShow: [], all: [] });
|
|
1135
|
-
}
|
|
1136
|
-
technologyMap.get(technologyId)!.all.push(appointment);
|
|
1137
|
-
});
|
|
1138
|
-
|
|
1139
|
-
noShow.forEach(appointment => {
|
|
1140
|
-
const technologyId =
|
|
1141
|
-
appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
|
|
1142
|
-
if (technologyMap.has(technologyId)) {
|
|
1143
|
-
technologyMap.get(technologyId)!.noShow.push(appointment);
|
|
1144
|
-
}
|
|
1145
|
-
});
|
|
1146
|
-
|
|
1147
|
-
return Array.from(technologyMap.entries()).map(([technologyId, data]) => ({
|
|
1148
|
-
entityId: technologyId,
|
|
1149
|
-
entityName: data.name,
|
|
1150
|
-
entityType: 'technology' as EntityType,
|
|
1151
|
-
totalAppointments: data.all.length,
|
|
1152
|
-
noShowAppointments: data.noShow.length,
|
|
1153
|
-
noShowRate: calculatePercentage(data.noShow.length, data.all.length),
|
|
1154
|
-
}));
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
// ==========================================
|
|
1158
|
-
// Financial Analytics
|
|
1159
|
-
// ==========================================
|
|
1160
|
-
|
|
1161
|
-
/**
|
|
1162
|
-
* Get revenue metrics grouped by clinic, practitioner, procedure, patient, or technology
|
|
1163
|
-
*
|
|
1164
|
-
* @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient' | 'technology'
|
|
1165
|
-
* @param dateRange - Optional date range filter
|
|
1166
|
-
* @param filters - Optional additional filters
|
|
1167
|
-
* @returns Grouped revenue metrics
|
|
1168
|
-
*/
|
|
1169
|
-
async getRevenueMetricsByEntity(
|
|
1170
|
-
groupBy: EntityType,
|
|
1171
|
-
dateRange?: AnalyticsDateRange,
|
|
1172
|
-
filters?: AnalyticsFilters,
|
|
1173
|
-
): Promise<GroupedRevenueMetrics[]> {
|
|
1174
|
-
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
1175
|
-
return calculateGroupedRevenueMetrics(appointments, groupBy);
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
/**
|
|
1179
|
-
* Get revenue metrics
|
|
1180
|
-
* First checks for stored analytics, then calculates if not available or stale
|
|
1181
|
-
*
|
|
1182
|
-
* IMPORTANT: Financial calculations only consider COMPLETED appointments.
|
|
1183
|
-
* Confirmed, pending, canceled, and no-show appointments are NOT included in revenue calculations.
|
|
1184
|
-
* Only procedures that have been completed generate revenue.
|
|
1185
|
-
*
|
|
1186
|
-
* @param filters - Optional filters
|
|
1187
|
-
* @param dateRange - Optional date range filter
|
|
1188
|
-
* @param options - Options for reading stored analytics
|
|
1189
|
-
* @returns Revenue metrics
|
|
1190
|
-
*/
|
|
1191
|
-
async getRevenueMetrics(
|
|
1192
|
-
filters?: AnalyticsFilters,
|
|
1193
|
-
dateRange?: AnalyticsDateRange,
|
|
1194
|
-
options?: ReadStoredAnalyticsOptions,
|
|
1195
|
-
): Promise<RevenueMetrics> {
|
|
1196
|
-
// Try to read from stored analytics first
|
|
1197
|
-
if (filters?.clinicBranchId && dateRange && options?.useCache !== false) {
|
|
1198
|
-
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
1199
|
-
const stored = await readStoredRevenueMetrics(
|
|
1200
|
-
this.db,
|
|
1201
|
-
filters.clinicBranchId,
|
|
1202
|
-
{ ...options, period },
|
|
1203
|
-
);
|
|
1204
|
-
|
|
1205
|
-
if (stored) {
|
|
1206
|
-
// Return stored data (without metadata)
|
|
1207
|
-
const { metadata, ...metrics } = stored;
|
|
1208
|
-
return metrics;
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
// Fall back to calculation
|
|
1213
|
-
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
1214
|
-
const completed = getCompletedAppointments(appointments);
|
|
1215
|
-
|
|
1216
|
-
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
1217
|
-
|
|
1218
|
-
// Calculate revenue by status - ONLY for COMPLETED appointments
|
|
1219
|
-
// Financial calculations should only consider completed procedures
|
|
1220
|
-
const revenueByStatus: Partial<Record<AppointmentStatus, number>> = {};
|
|
1221
|
-
// Only calculate revenue for COMPLETED status (other statuses have no revenue)
|
|
1222
|
-
const { totalRevenue: completedRevenue } = calculateTotalRevenue(completed);
|
|
1223
|
-
revenueByStatus[AppointmentStatus.COMPLETED] = completedRevenue;
|
|
1224
|
-
// All other statuses have 0 revenue (confirmed, pending, canceled, etc. don't generate revenue)
|
|
1225
|
-
|
|
1226
|
-
// Calculate revenue by payment status
|
|
1227
|
-
const revenueByPaymentStatus: Partial<Record<PaymentStatus, number>> = {};
|
|
1228
|
-
Object.values(PaymentStatus).forEach(paymentStatus => {
|
|
1229
|
-
const paymentAppointments = completed.filter(a => a.paymentStatus === paymentStatus);
|
|
1230
|
-
const { totalRevenue: paymentRevenue } = calculateTotalRevenue(paymentAppointments);
|
|
1231
|
-
revenueByPaymentStatus[paymentStatus] = paymentRevenue;
|
|
1232
|
-
});
|
|
1233
|
-
|
|
1234
|
-
const unpaid = completed.filter(a => a.paymentStatus === PaymentStatus.UNPAID);
|
|
1235
|
-
const refunded = completed.filter(a => a.paymentStatus === PaymentStatus.REFUNDED);
|
|
1236
|
-
|
|
1237
|
-
const { totalRevenue: unpaidRevenue } = calculateTotalRevenue(unpaid);
|
|
1238
|
-
const { totalRevenue: refundedRevenue } = calculateTotalRevenue(refunded);
|
|
1239
|
-
|
|
1240
|
-
// Calculate tax and subtotal from finalbilling if available
|
|
1241
|
-
let totalTax = 0;
|
|
1242
|
-
let totalSubtotal = 0;
|
|
1243
|
-
completed.forEach(appointment => {
|
|
1244
|
-
const costData = calculateAppointmentCost(appointment);
|
|
1245
|
-
if (costData.source === 'finalbilling') {
|
|
1246
|
-
totalTax += costData.tax || 0;
|
|
1247
|
-
totalSubtotal += costData.subtotal || 0;
|
|
1248
|
-
} else {
|
|
1249
|
-
totalSubtotal += costData.cost;
|
|
1250
|
-
}
|
|
1251
|
-
});
|
|
1252
|
-
|
|
1253
|
-
return {
|
|
1254
|
-
totalRevenue,
|
|
1255
|
-
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
1256
|
-
totalAppointments: appointments.length,
|
|
1257
|
-
completedAppointments: completed.length,
|
|
1258
|
-
currency,
|
|
1259
|
-
revenueByStatus,
|
|
1260
|
-
revenueByPaymentStatus,
|
|
1261
|
-
unpaidRevenue,
|
|
1262
|
-
refundedRevenue,
|
|
1263
|
-
totalTax,
|
|
1264
|
-
totalSubtotal,
|
|
1265
|
-
};
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
// ==========================================
|
|
1269
|
-
// Product Usage Analytics
|
|
1270
|
-
// ==========================================
|
|
1271
|
-
|
|
1272
|
-
/**
|
|
1273
|
-
* Get product usage metrics grouped by clinic, practitioner, procedure, or patient
|
|
1274
|
-
*
|
|
1275
|
-
* @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient'
|
|
1276
|
-
* @param dateRange - Optional date range filter
|
|
1277
|
-
* @param filters - Optional additional filters
|
|
1278
|
-
* @returns Grouped product usage metrics
|
|
1279
|
-
*/
|
|
1280
|
-
async getProductUsageMetricsByEntity(
|
|
1281
|
-
groupBy: EntityType,
|
|
1282
|
-
dateRange?: AnalyticsDateRange,
|
|
1283
|
-
filters?: AnalyticsFilters,
|
|
1284
|
-
): Promise<GroupedProductUsageMetrics[]> {
|
|
1285
|
-
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
1286
|
-
return calculateGroupedProductUsageMetrics(appointments, groupBy);
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
/**
|
|
1290
|
-
* Get product usage metrics
|
|
1291
|
-
*
|
|
1292
|
-
* IMPORTANT: Only COMPLETED appointments are included in product usage calculations.
|
|
1293
|
-
* Products are only considered "used" when the procedure has been completed.
|
|
1294
|
-
* Confirmed, pending, canceled, and no-show appointments are excluded from product metrics.
|
|
1295
|
-
*
|
|
1296
|
-
* @param productId - Optional product ID (if not provided, returns all products)
|
|
1297
|
-
* @param dateRange - Optional date range filter
|
|
1298
|
-
* @param filters - Optional filters (e.g., clinicBranchId)
|
|
1299
|
-
* @returns Product usage metrics
|
|
1300
|
-
*/
|
|
1301
|
-
async getProductUsageMetrics(
|
|
1302
|
-
productId?: string,
|
|
1303
|
-
dateRange?: AnalyticsDateRange,
|
|
1304
|
-
filters?: AnalyticsFilters,
|
|
1305
|
-
): Promise<ProductUsageMetrics | ProductUsageMetrics[]> {
|
|
1306
|
-
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
1307
|
-
const completed = getCompletedAppointments(appointments);
|
|
1308
|
-
|
|
1309
|
-
const productMap = new Map<
|
|
1310
|
-
string,
|
|
1311
|
-
{
|
|
1312
|
-
name: string;
|
|
1313
|
-
brandId: string;
|
|
1314
|
-
brandName: string;
|
|
1315
|
-
quantity: number;
|
|
1316
|
-
revenue: number;
|
|
1317
|
-
usageCount: number;
|
|
1318
|
-
appointmentIds: Set<string>; // Track which appointments used this product
|
|
1319
|
-
procedureMap: Map<string, { name: string; count: number; quantity: number }>;
|
|
1320
|
-
}
|
|
1321
|
-
>();
|
|
1322
|
-
|
|
1323
|
-
completed.forEach(appointment => {
|
|
1324
|
-
const products = extractProductUsage(appointment);
|
|
1325
|
-
// Track which products were used in this appointment to count appointments, not product entries
|
|
1326
|
-
const productsInThisAppointment = new Set<string>();
|
|
1327
|
-
|
|
1328
|
-
products.forEach(product => {
|
|
1329
|
-
if (productId && product.productId !== productId) {
|
|
1330
|
-
return;
|
|
1331
|
-
}
|
|
1332
|
-
|
|
1333
|
-
if (!productMap.has(product.productId)) {
|
|
1334
|
-
productMap.set(product.productId, {
|
|
1335
|
-
name: product.productName,
|
|
1336
|
-
brandId: product.brandId,
|
|
1337
|
-
brandName: product.brandName,
|
|
1338
|
-
quantity: 0,
|
|
1339
|
-
revenue: 0,
|
|
1340
|
-
usageCount: 0,
|
|
1341
|
-
appointmentIds: new Set(),
|
|
1342
|
-
procedureMap: new Map(),
|
|
1343
|
-
});
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
const productData = productMap.get(product.productId)!;
|
|
1347
|
-
productData.quantity += product.quantity;
|
|
1348
|
-
productData.revenue += product.subtotal;
|
|
1349
|
-
|
|
1350
|
-
// Track that this product was used in this appointment
|
|
1351
|
-
productsInThisAppointment.add(product.productId);
|
|
1352
|
-
});
|
|
1353
|
-
|
|
1354
|
-
// After processing all products from this appointment, increment usageCount once per product
|
|
1355
|
-
productsInThisAppointment.forEach(productId => {
|
|
1356
|
-
const productData = productMap.get(productId)!;
|
|
1357
|
-
if (!productData.appointmentIds.has(appointment.id)) {
|
|
1358
|
-
productData.appointmentIds.add(appointment.id);
|
|
1359
|
-
productData.usageCount++;
|
|
1360
|
-
|
|
1361
|
-
// Track usage by procedure (only once per appointment)
|
|
1362
|
-
const procId = appointment.procedureId;
|
|
1363
|
-
const procName = appointment.procedureInfo?.name || 'Unknown';
|
|
1364
|
-
if (productData.procedureMap.has(procId)) {
|
|
1365
|
-
const procData = productData.procedureMap.get(procId)!;
|
|
1366
|
-
procData.count++;
|
|
1367
|
-
// Sum all quantities for this product in this appointment
|
|
1368
|
-
const appointmentProducts = products.filter(p => p.productId === productId);
|
|
1369
|
-
procData.quantity += appointmentProducts.reduce((sum, p) => sum + p.quantity, 0);
|
|
1370
|
-
} else {
|
|
1371
|
-
const appointmentProducts = products.filter(p => p.productId === productId);
|
|
1372
|
-
const totalQuantity = appointmentProducts.reduce((sum, p) => sum + p.quantity, 0);
|
|
1373
|
-
productData.procedureMap.set(procId, {
|
|
1374
|
-
name: procName,
|
|
1375
|
-
count: 1,
|
|
1376
|
-
quantity: totalQuantity,
|
|
1377
|
-
});
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
});
|
|
1381
|
-
});
|
|
1382
|
-
|
|
1383
|
-
const results = Array.from(productMap.entries()).map(([productId, data]) => ({
|
|
1384
|
-
productId,
|
|
1385
|
-
productName: data.name,
|
|
1386
|
-
brandId: data.brandId,
|
|
1387
|
-
brandName: data.brandName,
|
|
1388
|
-
totalQuantity: data.quantity,
|
|
1389
|
-
totalRevenue: data.revenue,
|
|
1390
|
-
averagePrice: data.usageCount > 0 ? data.revenue / data.quantity : 0,
|
|
1391
|
-
currency: 'CHF', // Could be extracted from products
|
|
1392
|
-
usageCount: data.usageCount,
|
|
1393
|
-
averageQuantityPerAppointment:
|
|
1394
|
-
data.usageCount > 0 ? data.quantity / data.usageCount : 0,
|
|
1395
|
-
usageByProcedure: Array.from(data.procedureMap.entries()).map(([procId, procData]) => ({
|
|
1396
|
-
procedureId: procId,
|
|
1397
|
-
procedureName: procData.name,
|
|
1398
|
-
count: procData.count,
|
|
1399
|
-
totalQuantity: procData.quantity,
|
|
1400
|
-
})),
|
|
1401
|
-
}));
|
|
1402
|
-
|
|
1403
|
-
return productId ? results[0] : results;
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
// ==========================================
|
|
1407
|
-
// Patient Analytics
|
|
1408
|
-
// ==========================================
|
|
1409
|
-
|
|
1410
|
-
/**
|
|
1411
|
-
* Get patient behavior metrics grouped by clinic, practitioner, procedure, or technology
|
|
1412
|
-
* Shows patient no-show and cancellation patterns per entity
|
|
1413
|
-
*
|
|
1414
|
-
* @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'technology'
|
|
1415
|
-
* @param dateRange - Optional date range filter
|
|
1416
|
-
* @param filters - Optional additional filters
|
|
1417
|
-
* @returns Grouped patient behavior metrics
|
|
1418
|
-
*/
|
|
1419
|
-
async getPatientBehaviorMetricsByEntity(
|
|
1420
|
-
groupBy: 'clinic' | 'practitioner' | 'procedure' | 'technology',
|
|
1421
|
-
dateRange?: AnalyticsDateRange,
|
|
1422
|
-
filters?: AnalyticsFilters,
|
|
1423
|
-
): Promise<GroupedPatientBehaviorMetrics[]> {
|
|
1424
|
-
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
1425
|
-
return calculateGroupedPatientBehaviorMetrics(appointments, groupBy);
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
/**
|
|
1429
|
-
* Get patient analytics
|
|
1430
|
-
*
|
|
1431
|
-
* @param patientId - Optional patient ID (if not provided, returns aggregate)
|
|
1432
|
-
* @param dateRange - Optional date range filter
|
|
1433
|
-
* @returns Patient analytics
|
|
1434
|
-
*/
|
|
1435
|
-
async getPatientAnalytics(
|
|
1436
|
-
patientId?: string,
|
|
1437
|
-
dateRange?: AnalyticsDateRange,
|
|
1438
|
-
): Promise<PatientAnalytics | PatientAnalytics[]> {
|
|
1439
|
-
const appointments = await this.fetchAppointments(patientId ? { patientId } : undefined, dateRange);
|
|
1440
|
-
|
|
1441
|
-
if (patientId) {
|
|
1442
|
-
return this.calculatePatientAnalytics(appointments, patientId);
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
// Group by patient
|
|
1446
|
-
const patientMap = new Map<string, Appointment[]>();
|
|
1447
|
-
appointments.forEach(appointment => {
|
|
1448
|
-
const patId = appointment.patientId;
|
|
1449
|
-
if (!patientMap.has(patId)) {
|
|
1450
|
-
patientMap.set(patId, []);
|
|
1451
|
-
}
|
|
1452
|
-
patientMap.get(patId)!.push(appointment);
|
|
1453
|
-
});
|
|
1454
|
-
|
|
1455
|
-
return Array.from(patientMap.entries()).map(([patId, patAppointments]) =>
|
|
1456
|
-
this.calculatePatientAnalytics(patAppointments, patId),
|
|
1457
|
-
);
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
/**
|
|
1461
|
-
* Calculate analytics for a specific patient
|
|
1462
|
-
*/
|
|
1463
|
-
private calculatePatientAnalytics(appointments: Appointment[], patientId: string): PatientAnalytics {
|
|
1464
|
-
const completed = getCompletedAppointments(appointments);
|
|
1465
|
-
const canceled = getCanceledAppointments(appointments);
|
|
1466
|
-
const noShow = getNoShowAppointments(appointments);
|
|
1467
|
-
|
|
1468
|
-
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
1469
|
-
|
|
1470
|
-
// Get appointment dates
|
|
1471
|
-
const appointmentDates = appointments
|
|
1472
|
-
.map(a => a.appointmentStartTime.toDate())
|
|
1473
|
-
.sort((a, b) => a.getTime() - b.getTime());
|
|
1474
|
-
|
|
1475
|
-
const firstAppointmentDate = appointmentDates.length > 0 ? appointmentDates[0] : null;
|
|
1476
|
-
const lastAppointmentDate =
|
|
1477
|
-
appointmentDates.length > 0 ? appointmentDates[appointmentDates.length - 1] : null;
|
|
1478
|
-
|
|
1479
|
-
// Calculate average days between appointments
|
|
1480
|
-
let averageDaysBetween = null;
|
|
1481
|
-
if (appointmentDates.length > 1) {
|
|
1482
|
-
const intervals: number[] = [];
|
|
1483
|
-
for (let i = 1; i < appointmentDates.length; i++) {
|
|
1484
|
-
const diffMs = appointmentDates[i].getTime() - appointmentDates[i - 1].getTime();
|
|
1485
|
-
intervals.push(diffMs / (1000 * 60 * 60 * 24));
|
|
1486
|
-
}
|
|
1487
|
-
averageDaysBetween = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
// Get unique practitioners and clinics
|
|
1491
|
-
const uniquePractitioners = new Set(appointments.map(a => a.practitionerId));
|
|
1492
|
-
const uniqueClinics = new Set(appointments.map(a => a.clinicBranchId));
|
|
1493
|
-
|
|
1494
|
-
// Get favorite procedures
|
|
1495
|
-
const procedureMap = new Map<string, { name: string; count: number }>();
|
|
1496
|
-
completed.forEach(appointment => {
|
|
1497
|
-
const procId = appointment.procedureId;
|
|
1498
|
-
const procName = appointment.procedureInfo?.name || 'Unknown';
|
|
1499
|
-
procedureMap.set(procId, {
|
|
1500
|
-
name: procName,
|
|
1501
|
-
count: (procedureMap.get(procId)?.count || 0) + 1,
|
|
1502
|
-
});
|
|
1503
|
-
});
|
|
1504
|
-
|
|
1505
|
-
const favoriteProcedures = Array.from(procedureMap.entries())
|
|
1506
|
-
.map(([procedureId, data]) => ({
|
|
1507
|
-
procedureId,
|
|
1508
|
-
procedureName: data.name,
|
|
1509
|
-
count: data.count,
|
|
1510
|
-
}))
|
|
1511
|
-
.sort((a, b) => b.count - a.count)
|
|
1512
|
-
.slice(0, 5);
|
|
1513
|
-
|
|
1514
|
-
const patientName = appointments.length > 0 ? appointments[0].patientInfo?.fullName || 'Unknown' : 'Unknown';
|
|
1515
|
-
|
|
1516
|
-
return {
|
|
1517
|
-
patientId,
|
|
1518
|
-
patientName,
|
|
1519
|
-
totalAppointments: appointments.length,
|
|
1520
|
-
completedAppointments: completed.length,
|
|
1521
|
-
canceledAppointments: canceled.length,
|
|
1522
|
-
noShowAppointments: noShow.length,
|
|
1523
|
-
cancellationRate: calculatePercentage(canceled.length, appointments.length),
|
|
1524
|
-
noShowRate: calculatePercentage(noShow.length, appointments.length),
|
|
1525
|
-
totalRevenue,
|
|
1526
|
-
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
1527
|
-
currency,
|
|
1528
|
-
lifetimeValue: totalRevenue,
|
|
1529
|
-
firstAppointmentDate,
|
|
1530
|
-
lastAppointmentDate,
|
|
1531
|
-
averageDaysBetweenAppointments: averageDaysBetween ? Math.round(averageDaysBetween) : null,
|
|
1532
|
-
uniquePractitioners: uniquePractitioners.size,
|
|
1533
|
-
uniqueClinics: uniqueClinics.size,
|
|
1534
|
-
favoriteProcedures,
|
|
1535
|
-
};
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
// ==========================================
|
|
1539
|
-
// Dashboard Analytics
|
|
1540
|
-
// ==========================================
|
|
1541
|
-
|
|
1542
|
-
/**
|
|
1543
|
-
* Determines analytics period from date range
|
|
1544
|
-
*/
|
|
1545
|
-
private determinePeriodFromDateRange(dateRange: AnalyticsDateRange): AnalyticsPeriod {
|
|
1546
|
-
const diffMs = dateRange.end.getTime() - dateRange.start.getTime();
|
|
1547
|
-
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
|
1548
|
-
|
|
1549
|
-
if (diffDays <= 1) return 'daily';
|
|
1550
|
-
if (diffDays <= 7) return 'weekly';
|
|
1551
|
-
if (diffDays <= 31) return 'monthly';
|
|
1552
|
-
if (diffDays <= 365) return 'yearly';
|
|
1553
|
-
return 'all_time';
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
/**
|
|
1557
|
-
* Get comprehensive dashboard data
|
|
1558
|
-
* First checks for stored analytics, then calculates if not available or stale
|
|
1559
|
-
*
|
|
1560
|
-
* @param filters - Optional filters
|
|
1561
|
-
* @param dateRange - Optional date range filter
|
|
1562
|
-
* @param options - Options for reading stored analytics
|
|
1563
|
-
* @returns Complete dashboard analytics
|
|
1564
|
-
*/
|
|
1565
|
-
async getDashboardData(
|
|
1566
|
-
filters?: AnalyticsFilters,
|
|
1567
|
-
dateRange?: AnalyticsDateRange,
|
|
1568
|
-
options?: ReadStoredAnalyticsOptions,
|
|
1569
|
-
): Promise<DashboardAnalytics> {
|
|
1570
|
-
// Try to read from stored analytics first
|
|
1571
|
-
if (filters?.clinicBranchId && dateRange && options?.useCache !== false) {
|
|
1572
|
-
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
1573
|
-
const stored = await readStoredDashboardAnalytics(
|
|
1574
|
-
this.db,
|
|
1575
|
-
filters.clinicBranchId,
|
|
1576
|
-
{ ...options, period },
|
|
1577
|
-
);
|
|
1578
|
-
|
|
1579
|
-
if (stored) {
|
|
1580
|
-
const { metadata, ...analytics } = stored;
|
|
1581
|
-
return analytics;
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
|
-
// Fall back to calculation
|
|
1586
|
-
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
1587
|
-
|
|
1588
|
-
const completed = getCompletedAppointments(appointments);
|
|
1589
|
-
const canceled = getCanceledAppointments(appointments);
|
|
1590
|
-
const noShow = getNoShowAppointments(appointments);
|
|
1591
|
-
const pending = appointments.filter(a => a.status === AppointmentStatus.PENDING);
|
|
1592
|
-
const confirmed = appointments.filter(a => a.status === AppointmentStatus.CONFIRMED);
|
|
1593
|
-
|
|
1594
|
-
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
1595
|
-
|
|
1596
|
-
// Get unique counts
|
|
1597
|
-
const uniquePatients = new Set(appointments.map(a => a.patientId));
|
|
1598
|
-
const uniquePractitioners = new Set(appointments.map(a => a.practitionerId));
|
|
1599
|
-
const uniqueProcedures = new Set(appointments.map(a => a.procedureId));
|
|
1600
|
-
|
|
1601
|
-
// Get top practitioners (limit to 5)
|
|
1602
|
-
const practitionerMetrics = await Promise.all(
|
|
1603
|
-
Array.from(uniquePractitioners)
|
|
1604
|
-
.slice(0, 5)
|
|
1605
|
-
.map(practitionerId => this.getPractitionerAnalytics(practitionerId, dateRange)),
|
|
1606
|
-
);
|
|
1607
|
-
|
|
1608
|
-
// Get top procedures (limit to 5)
|
|
1609
|
-
const procedureMetricsResults = await Promise.all(
|
|
1610
|
-
Array.from(uniqueProcedures)
|
|
1611
|
-
.slice(0, 5)
|
|
1612
|
-
.map(procedureId => this.getProcedureAnalytics(procedureId, dateRange)),
|
|
1613
|
-
);
|
|
1614
|
-
// Filter out arrays and ensure we have ProcedureAnalytics objects
|
|
1615
|
-
const procedureMetrics = procedureMetricsResults.filter(
|
|
1616
|
-
(result): result is ProcedureAnalytics => !Array.isArray(result),
|
|
1617
|
-
);
|
|
1618
|
-
|
|
1619
|
-
// Get cancellation and no-show metrics (aggregated)
|
|
1620
|
-
const cancellationMetrics = await this.getCancellationMetrics('clinic', dateRange);
|
|
1621
|
-
const noShowMetrics = await this.getNoShowMetrics('clinic', dateRange);
|
|
1622
|
-
|
|
1623
|
-
// Get time efficiency
|
|
1624
|
-
const timeEfficiency = await this.getTimeEfficiencyMetrics(filters, dateRange);
|
|
1625
|
-
|
|
1626
|
-
// Get top products
|
|
1627
|
-
const productMetrics = await this.getProductUsageMetrics(undefined, dateRange);
|
|
1628
|
-
const topProducts = Array.isArray(productMetrics)
|
|
1629
|
-
? productMetrics.sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, 5)
|
|
1630
|
-
: [];
|
|
1631
|
-
|
|
1632
|
-
// Get recent activity (last 10 appointments)
|
|
1633
|
-
const recentActivity = appointments
|
|
1634
|
-
.sort((a, b) => b.appointmentStartTime.toMillis() - a.appointmentStartTime.toMillis())
|
|
1635
|
-
.slice(0, 10)
|
|
1636
|
-
.map(appointment => {
|
|
1637
|
-
let type: 'appointment' | 'cancellation' | 'completion' | 'no_show' = 'appointment';
|
|
1638
|
-
let description = '';
|
|
1639
|
-
|
|
1640
|
-
if (appointment.status === AppointmentStatus.COMPLETED) {
|
|
1641
|
-
type = 'completion';
|
|
1642
|
-
description = `Appointment completed: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
|
|
1643
|
-
} else if (
|
|
1644
|
-
appointment.status === AppointmentStatus.CANCELED_PATIENT ||
|
|
1645
|
-
appointment.status === AppointmentStatus.CANCELED_CLINIC ||
|
|
1646
|
-
appointment.status === AppointmentStatus.CANCELED_PATIENT_RESCHEDULED
|
|
1647
|
-
) {
|
|
1648
|
-
type = 'cancellation';
|
|
1649
|
-
description = `Appointment canceled: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
|
|
1650
|
-
} else if (appointment.status === AppointmentStatus.NO_SHOW) {
|
|
1651
|
-
type = 'no_show';
|
|
1652
|
-
description = `No-show: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
|
|
1653
|
-
} else {
|
|
1654
|
-
description = `Appointment ${appointment.status}: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
|
|
1655
|
-
}
|
|
1656
|
-
|
|
1657
|
-
return {
|
|
1658
|
-
type,
|
|
1659
|
-
date: appointment.appointmentStartTime.toDate(),
|
|
1660
|
-
description,
|
|
1661
|
-
entityId: appointment.practitionerId,
|
|
1662
|
-
entityName: appointment.practitionerInfo?.name || 'Unknown',
|
|
1663
|
-
};
|
|
1664
|
-
});
|
|
1665
|
-
|
|
1666
|
-
return {
|
|
1667
|
-
overview: {
|
|
1668
|
-
totalAppointments: appointments.length,
|
|
1669
|
-
completedAppointments: completed.length,
|
|
1670
|
-
canceledAppointments: canceled.length,
|
|
1671
|
-
noShowAppointments: noShow.length,
|
|
1672
|
-
pendingAppointments: pending.length,
|
|
1673
|
-
confirmedAppointments: confirmed.length,
|
|
1674
|
-
totalRevenue,
|
|
1675
|
-
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
1676
|
-
currency,
|
|
1677
|
-
uniquePatients: uniquePatients.size,
|
|
1678
|
-
uniquePractitioners: uniquePractitioners.size,
|
|
1679
|
-
uniqueProcedures: uniqueProcedures.size,
|
|
1680
|
-
cancellationRate: calculatePercentage(canceled.length, appointments.length),
|
|
1681
|
-
noShowRate: calculatePercentage(noShow.length, appointments.length),
|
|
1682
|
-
},
|
|
1683
|
-
practitionerMetrics: Array.isArray(practitionerMetrics) ? practitionerMetrics : [],
|
|
1684
|
-
procedureMetrics: Array.isArray(procedureMetrics) ? procedureMetrics : [],
|
|
1685
|
-
cancellationMetrics: Array.isArray(cancellationMetrics) ? cancellationMetrics[0] : cancellationMetrics,
|
|
1686
|
-
noShowMetrics: Array.isArray(noShowMetrics) ? noShowMetrics[0] : noShowMetrics,
|
|
1687
|
-
revenueTrends: [], // TODO: Implement revenue trends
|
|
1688
|
-
timeEfficiency,
|
|
1689
|
-
topProducts,
|
|
1690
|
-
recentActivity,
|
|
1691
|
-
};
|
|
1692
|
-
}
|
|
1693
|
-
|
|
1694
|
-
/**
|
|
1695
|
-
* Calculate revenue trends over time
|
|
1696
|
-
* Groups appointments by week/month/quarter/year and calculates revenue metrics
|
|
1697
|
-
*
|
|
1698
|
-
* @param dateRange - Date range for trend analysis (must align with period boundaries)
|
|
1699
|
-
* @param period - Period type (week, month, quarter, year)
|
|
1700
|
-
* @param filters - Optional filters for clinic, practitioner, procedure, patient
|
|
1701
|
-
* @param groupBy - Optional entity type to group trends by (clinic, practitioner, procedure, technology, patient)
|
|
1702
|
-
* @returns Array of revenue trends with percentage changes
|
|
1703
|
-
*/
|
|
1704
|
-
async getRevenueTrends(
|
|
1705
|
-
dateRange: AnalyticsDateRange,
|
|
1706
|
-
period: TrendPeriod,
|
|
1707
|
-
filters?: AnalyticsFilters,
|
|
1708
|
-
groupBy?: EntityType,
|
|
1709
|
-
): Promise<RevenueTrend[]> {
|
|
1710
|
-
const appointments = await this.fetchAppointments(filters);
|
|
1711
|
-
const filtered = filterByDateRange(appointments, dateRange);
|
|
1712
|
-
|
|
1713
|
-
if (filtered.length === 0) {
|
|
1714
|
-
return [];
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
// If grouping by entity, calculate trends per entity
|
|
1718
|
-
if (groupBy) {
|
|
1719
|
-
return this.getGroupedRevenueTrends(filtered, dateRange, period, groupBy);
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
|
-
// Calculate overall trends
|
|
1723
|
-
const periodMap = groupAppointmentsByPeriod(filtered, period as TrendPeriodType);
|
|
1724
|
-
const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
|
|
1725
|
-
const trends: RevenueTrend[] = [];
|
|
1726
|
-
|
|
1727
|
-
let previousRevenue = 0;
|
|
1728
|
-
let previousAppointmentCount = 0;
|
|
1729
|
-
|
|
1730
|
-
periods.forEach(periodInfo => {
|
|
1731
|
-
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
1732
|
-
const completed = getCompletedAppointments(periodAppointments);
|
|
1733
|
-
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
1734
|
-
|
|
1735
|
-
const appointmentCount = completed.length;
|
|
1736
|
-
const averageRevenue = appointmentCount > 0 ? totalRevenue / appointmentCount : 0;
|
|
1737
|
-
|
|
1738
|
-
const trend: RevenueTrend = {
|
|
1739
|
-
period: periodInfo.period,
|
|
1740
|
-
startDate: periodInfo.startDate,
|
|
1741
|
-
endDate: periodInfo.endDate,
|
|
1742
|
-
revenue: totalRevenue,
|
|
1743
|
-
appointmentCount,
|
|
1744
|
-
averageRevenue,
|
|
1745
|
-
currency,
|
|
1746
|
-
};
|
|
1747
|
-
|
|
1748
|
-
// Calculate percentage change from previous period
|
|
1749
|
-
if (previousRevenue > 0 || previousAppointmentCount > 0) {
|
|
1750
|
-
const revenueChange = getTrendChange(totalRevenue, previousRevenue);
|
|
1751
|
-
trend.previousPeriod = {
|
|
1752
|
-
revenue: previousRevenue,
|
|
1753
|
-
appointmentCount: previousAppointmentCount,
|
|
1754
|
-
percentageChange: revenueChange.percentageChange,
|
|
1755
|
-
direction: revenueChange.direction,
|
|
1756
|
-
};
|
|
1757
|
-
}
|
|
1758
|
-
|
|
1759
|
-
trends.push(trend);
|
|
1760
|
-
previousRevenue = totalRevenue;
|
|
1761
|
-
previousAppointmentCount = appointmentCount;
|
|
1762
|
-
});
|
|
1763
|
-
|
|
1764
|
-
return trends;
|
|
1765
|
-
}
|
|
1766
|
-
|
|
1767
|
-
/**
|
|
1768
|
-
* Calculate revenue trends grouped by entity
|
|
1769
|
-
*/
|
|
1770
|
-
private async getGroupedRevenueTrends(
|
|
1771
|
-
appointments: Appointment[],
|
|
1772
|
-
dateRange: AnalyticsDateRange,
|
|
1773
|
-
period: TrendPeriod,
|
|
1774
|
-
groupBy: EntityType,
|
|
1775
|
-
): Promise<RevenueTrend[]> {
|
|
1776
|
-
const periodMap = groupAppointmentsByPeriod(appointments, period as TrendPeriodType);
|
|
1777
|
-
const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
|
|
1778
|
-
const trends: RevenueTrend[] = [];
|
|
1779
|
-
|
|
1780
|
-
// Group appointments by entity for each period
|
|
1781
|
-
periods.forEach(periodInfo => {
|
|
1782
|
-
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
1783
|
-
if (periodAppointments.length === 0) return;
|
|
1784
|
-
|
|
1785
|
-
const groupedMetrics = calculateGroupedRevenueMetrics(periodAppointments, groupBy);
|
|
1786
|
-
|
|
1787
|
-
// Sum up all entities for this period
|
|
1788
|
-
const totalRevenue = groupedMetrics.reduce((sum, m) => sum + m.totalRevenue, 0);
|
|
1789
|
-
const totalAppointments = groupedMetrics.reduce((sum, m) => sum + m.totalAppointments, 0);
|
|
1790
|
-
const currency = groupedMetrics[0]?.currency || 'CHF';
|
|
1791
|
-
const averageRevenue = totalAppointments > 0 ? totalRevenue / totalAppointments : 0;
|
|
1792
|
-
|
|
1793
|
-
trends.push({
|
|
1794
|
-
period: periodInfo.period,
|
|
1795
|
-
startDate: periodInfo.startDate,
|
|
1796
|
-
endDate: periodInfo.endDate,
|
|
1797
|
-
revenue: totalRevenue,
|
|
1798
|
-
appointmentCount: totalAppointments,
|
|
1799
|
-
averageRevenue,
|
|
1800
|
-
currency,
|
|
1801
|
-
});
|
|
1802
|
-
});
|
|
1803
|
-
|
|
1804
|
-
// Calculate percentage changes
|
|
1805
|
-
for (let i = 1; i < trends.length; i++) {
|
|
1806
|
-
const current = trends[i];
|
|
1807
|
-
const previous = trends[i - 1];
|
|
1808
|
-
const revenueChange = getTrendChange(current.revenue, previous.revenue);
|
|
1809
|
-
|
|
1810
|
-
current.previousPeriod = {
|
|
1811
|
-
revenue: previous.revenue,
|
|
1812
|
-
appointmentCount: previous.appointmentCount,
|
|
1813
|
-
percentageChange: revenueChange.percentageChange,
|
|
1814
|
-
direction: revenueChange.direction,
|
|
1815
|
-
};
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
|
-
return trends;
|
|
1819
|
-
}
|
|
1820
|
-
|
|
1821
|
-
/**
|
|
1822
|
-
* Calculate duration/efficiency trends over time
|
|
1823
|
-
*
|
|
1824
|
-
* @param dateRange - Date range for trend analysis
|
|
1825
|
-
* @param period - Period type (week, month, quarter, year)
|
|
1826
|
-
* @param filters - Optional filters
|
|
1827
|
-
* @param groupBy - Optional entity type to group trends by
|
|
1828
|
-
* @returns Array of duration trends with percentage changes
|
|
1829
|
-
*/
|
|
1830
|
-
async getDurationTrends(
|
|
1831
|
-
dateRange: AnalyticsDateRange,
|
|
1832
|
-
period: TrendPeriod,
|
|
1833
|
-
filters?: AnalyticsFilters,
|
|
1834
|
-
groupBy?: EntityType,
|
|
1835
|
-
): Promise<DurationTrend[]> {
|
|
1836
|
-
const appointments = await this.fetchAppointments(filters);
|
|
1837
|
-
const filtered = filterByDateRange(appointments, dateRange);
|
|
1838
|
-
|
|
1839
|
-
if (filtered.length === 0) {
|
|
1840
|
-
return [];
|
|
1841
|
-
}
|
|
1842
|
-
|
|
1843
|
-
const periodMap = groupAppointmentsByPeriod(filtered, period as TrendPeriodType);
|
|
1844
|
-
const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
|
|
1845
|
-
const trends: DurationTrend[] = [];
|
|
1846
|
-
|
|
1847
|
-
let previousEfficiency = 0;
|
|
1848
|
-
let previousBookedDuration = 0;
|
|
1849
|
-
let previousActualDuration = 0;
|
|
1850
|
-
|
|
1851
|
-
periods.forEach(periodInfo => {
|
|
1852
|
-
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
1853
|
-
const completed = getCompletedAppointments(periodAppointments);
|
|
1854
|
-
|
|
1855
|
-
if (groupBy) {
|
|
1856
|
-
// Group by entity and calculate average
|
|
1857
|
-
const groupedMetrics = calculateGroupedTimeEfficiencyMetrics(completed, groupBy);
|
|
1858
|
-
if (groupedMetrics.length === 0) return;
|
|
1859
|
-
|
|
1860
|
-
const totalAppointments = groupedMetrics.reduce((sum, m) => sum + m.totalAppointments, 0);
|
|
1861
|
-
const weightedBooked = groupedMetrics.reduce(
|
|
1862
|
-
(sum, m) => sum + m.averageBookedDuration * m.totalAppointments,
|
|
1863
|
-
0,
|
|
1864
|
-
);
|
|
1865
|
-
const weightedActual = groupedMetrics.reduce(
|
|
1866
|
-
(sum, m) => sum + m.averageActualDuration * m.totalAppointments,
|
|
1867
|
-
0,
|
|
1868
|
-
);
|
|
1869
|
-
const weightedEfficiency = groupedMetrics.reduce(
|
|
1870
|
-
(sum, m) => sum + m.averageEfficiency * m.totalAppointments,
|
|
1871
|
-
0,
|
|
1872
|
-
);
|
|
1873
|
-
|
|
1874
|
-
const averageBookedDuration = totalAppointments > 0 ? weightedBooked / totalAppointments : 0;
|
|
1875
|
-
const averageActualDuration = totalAppointments > 0 ? weightedActual / totalAppointments : 0;
|
|
1876
|
-
const averageEfficiency = totalAppointments > 0 ? weightedEfficiency / totalAppointments : 0;
|
|
1877
|
-
|
|
1878
|
-
const trend: DurationTrend = {
|
|
1879
|
-
period: periodInfo.period,
|
|
1880
|
-
startDate: periodInfo.startDate,
|
|
1881
|
-
endDate: periodInfo.endDate,
|
|
1882
|
-
averageBookedDuration,
|
|
1883
|
-
averageActualDuration,
|
|
1884
|
-
averageEfficiency,
|
|
1885
|
-
appointmentCount: totalAppointments,
|
|
1886
|
-
};
|
|
1887
|
-
|
|
1888
|
-
if (previousEfficiency > 0) {
|
|
1889
|
-
const efficiencyChange = getTrendChange(averageEfficiency, previousEfficiency);
|
|
1890
|
-
trend.previousPeriod = {
|
|
1891
|
-
averageBookedDuration: previousBookedDuration,
|
|
1892
|
-
averageActualDuration: previousActualDuration,
|
|
1893
|
-
averageEfficiency: previousEfficiency,
|
|
1894
|
-
efficiencyPercentageChange: efficiencyChange.percentageChange,
|
|
1895
|
-
direction: efficiencyChange.direction,
|
|
1896
|
-
};
|
|
1897
|
-
}
|
|
1898
|
-
|
|
1899
|
-
trends.push(trend);
|
|
1900
|
-
previousEfficiency = averageEfficiency;
|
|
1901
|
-
previousBookedDuration = averageBookedDuration;
|
|
1902
|
-
previousActualDuration = averageActualDuration;
|
|
1903
|
-
} else {
|
|
1904
|
-
// Overall trends
|
|
1905
|
-
const timeMetrics = calculateAverageTimeMetrics(completed);
|
|
1906
|
-
|
|
1907
|
-
const trend: DurationTrend = {
|
|
1908
|
-
period: periodInfo.period,
|
|
1909
|
-
startDate: periodInfo.startDate,
|
|
1910
|
-
endDate: periodInfo.endDate,
|
|
1911
|
-
averageBookedDuration: timeMetrics.averageBookedDuration,
|
|
1912
|
-
averageActualDuration: timeMetrics.averageActualDuration,
|
|
1913
|
-
averageEfficiency: timeMetrics.averageEfficiency,
|
|
1914
|
-
appointmentCount: timeMetrics.appointmentsWithActualTime,
|
|
1915
|
-
};
|
|
1916
|
-
|
|
1917
|
-
if (previousEfficiency > 0) {
|
|
1918
|
-
const efficiencyChange = getTrendChange(timeMetrics.averageEfficiency, previousEfficiency);
|
|
1919
|
-
trend.previousPeriod = {
|
|
1920
|
-
averageBookedDuration: previousBookedDuration,
|
|
1921
|
-
averageActualDuration: previousActualDuration,
|
|
1922
|
-
averageEfficiency: previousEfficiency,
|
|
1923
|
-
efficiencyPercentageChange: efficiencyChange.percentageChange,
|
|
1924
|
-
direction: efficiencyChange.direction,
|
|
1925
|
-
};
|
|
1926
|
-
}
|
|
1927
|
-
|
|
1928
|
-
trends.push(trend);
|
|
1929
|
-
previousEfficiency = timeMetrics.averageEfficiency;
|
|
1930
|
-
previousBookedDuration = timeMetrics.averageBookedDuration;
|
|
1931
|
-
previousActualDuration = timeMetrics.averageActualDuration;
|
|
1932
|
-
}
|
|
1933
|
-
});
|
|
1934
|
-
|
|
1935
|
-
return trends;
|
|
1936
|
-
}
|
|
1937
|
-
|
|
1938
|
-
/**
|
|
1939
|
-
* Calculate appointment count trends over time
|
|
1940
|
-
*
|
|
1941
|
-
* @param dateRange - Date range for trend analysis
|
|
1942
|
-
* @param period - Period type (week, month, quarter, year)
|
|
1943
|
-
* @param filters - Optional filters
|
|
1944
|
-
* @param groupBy - Optional entity type to group trends by
|
|
1945
|
-
* @returns Array of appointment trends with percentage changes
|
|
1946
|
-
*/
|
|
1947
|
-
async getAppointmentTrends(
|
|
1948
|
-
dateRange: AnalyticsDateRange,
|
|
1949
|
-
period: TrendPeriod,
|
|
1950
|
-
filters?: AnalyticsFilters,
|
|
1951
|
-
groupBy?: EntityType,
|
|
1952
|
-
): Promise<AppointmentTrend[]> {
|
|
1953
|
-
const appointments = await this.fetchAppointments(filters);
|
|
1954
|
-
const filtered = filterByDateRange(appointments, dateRange);
|
|
1955
|
-
|
|
1956
|
-
if (filtered.length === 0) {
|
|
1957
|
-
return [];
|
|
1958
|
-
}
|
|
1959
|
-
|
|
1960
|
-
const periodMap = groupAppointmentsByPeriod(filtered, period as TrendPeriodType);
|
|
1961
|
-
const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
|
|
1962
|
-
const trends: AppointmentTrend[] = [];
|
|
1963
|
-
|
|
1964
|
-
let previousTotal = 0;
|
|
1965
|
-
let previousCompleted = 0;
|
|
1966
|
-
|
|
1967
|
-
periods.forEach(periodInfo => {
|
|
1968
|
-
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
1969
|
-
const completed = getCompletedAppointments(periodAppointments);
|
|
1970
|
-
const canceled = getCanceledAppointments(periodAppointments);
|
|
1971
|
-
const noShow = getNoShowAppointments(periodAppointments);
|
|
1972
|
-
const pending = periodAppointments.filter(a => a.status === AppointmentStatus.PENDING);
|
|
1973
|
-
const confirmed = periodAppointments.filter(a => a.status === AppointmentStatus.CONFIRMED);
|
|
1974
|
-
|
|
1975
|
-
const trend: AppointmentTrend = {
|
|
1976
|
-
period: periodInfo.period,
|
|
1977
|
-
startDate: periodInfo.startDate,
|
|
1978
|
-
endDate: periodInfo.endDate,
|
|
1979
|
-
totalAppointments: periodAppointments.length,
|
|
1980
|
-
completedAppointments: completed.length,
|
|
1981
|
-
canceledAppointments: canceled.length,
|
|
1982
|
-
noShowAppointments: noShow.length,
|
|
1983
|
-
pendingAppointments: pending.length,
|
|
1984
|
-
confirmedAppointments: confirmed.length,
|
|
1985
|
-
};
|
|
1986
|
-
|
|
1987
|
-
if (previousTotal > 0) {
|
|
1988
|
-
const totalChange = getTrendChange(periodAppointments.length, previousTotal);
|
|
1989
|
-
trend.previousPeriod = {
|
|
1990
|
-
totalAppointments: previousTotal,
|
|
1991
|
-
completedAppointments: previousCompleted,
|
|
1992
|
-
percentageChange: totalChange.percentageChange,
|
|
1993
|
-
direction: totalChange.direction,
|
|
1994
|
-
};
|
|
1995
|
-
}
|
|
1996
|
-
|
|
1997
|
-
trends.push(trend);
|
|
1998
|
-
previousTotal = periodAppointments.length;
|
|
1999
|
-
previousCompleted = completed.length;
|
|
2000
|
-
});
|
|
2001
|
-
|
|
2002
|
-
return trends;
|
|
2003
|
-
}
|
|
2004
|
-
|
|
2005
|
-
/**
|
|
2006
|
-
* Calculate cancellation and no-show rate trends over time
|
|
2007
|
-
*
|
|
2008
|
-
* @param dateRange - Date range for trend analysis
|
|
2009
|
-
* @param period - Period type (week, month, quarter, year)
|
|
2010
|
-
* @param filters - Optional filters
|
|
2011
|
-
* @param groupBy - Optional entity type to group trends by
|
|
2012
|
-
* @returns Array of cancellation rate trends with percentage changes
|
|
2013
|
-
*/
|
|
2014
|
-
async getCancellationRateTrends(
|
|
2015
|
-
dateRange: AnalyticsDateRange,
|
|
2016
|
-
period: TrendPeriod,
|
|
2017
|
-
filters?: AnalyticsFilters,
|
|
2018
|
-
groupBy?: EntityType,
|
|
2019
|
-
): Promise<CancellationRateTrend[]> {
|
|
2020
|
-
const appointments = await this.fetchAppointments(filters);
|
|
2021
|
-
const filtered = filterByDateRange(appointments, dateRange);
|
|
2022
|
-
|
|
2023
|
-
if (filtered.length === 0) {
|
|
2024
|
-
return [];
|
|
2025
|
-
}
|
|
2026
|
-
|
|
2027
|
-
const periodMap = groupAppointmentsByPeriod(filtered, period as TrendPeriodType);
|
|
2028
|
-
const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
|
|
2029
|
-
const trends: CancellationRateTrend[] = [];
|
|
2030
|
-
|
|
2031
|
-
let previousCancellationRate = 0;
|
|
2032
|
-
let previousNoShowRate = 0;
|
|
2033
|
-
|
|
2034
|
-
periods.forEach(periodInfo => {
|
|
2035
|
-
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
2036
|
-
const canceled = getCanceledAppointments(periodAppointments);
|
|
2037
|
-
const noShow = getNoShowAppointments(periodAppointments);
|
|
2038
|
-
|
|
2039
|
-
const cancellationRate = calculatePercentage(canceled.length, periodAppointments.length);
|
|
2040
|
-
const noShowRate = calculatePercentage(noShow.length, periodAppointments.length);
|
|
2041
|
-
|
|
2042
|
-
const trend: CancellationRateTrend = {
|
|
2043
|
-
period: periodInfo.period,
|
|
2044
|
-
startDate: periodInfo.startDate,
|
|
2045
|
-
endDate: periodInfo.endDate,
|
|
2046
|
-
cancellationRate,
|
|
2047
|
-
noShowRate,
|
|
2048
|
-
totalAppointments: periodAppointments.length,
|
|
2049
|
-
canceledAppointments: canceled.length,
|
|
2050
|
-
noShowAppointments: noShow.length,
|
|
2051
|
-
};
|
|
2052
|
-
|
|
2053
|
-
if (previousCancellationRate > 0 || previousNoShowRate > 0) {
|
|
2054
|
-
const cancellationChange = getTrendChange(cancellationRate, previousCancellationRate);
|
|
2055
|
-
const noShowChange = getTrendChange(noShowRate, previousNoShowRate);
|
|
2056
|
-
|
|
2057
|
-
trend.previousPeriod = {
|
|
2058
|
-
cancellationRate: previousCancellationRate,
|
|
2059
|
-
noShowRate: previousNoShowRate,
|
|
2060
|
-
cancellationRateChange: cancellationChange.percentageChange,
|
|
2061
|
-
noShowRateChange: noShowChange.percentageChange,
|
|
2062
|
-
direction: cancellationChange.direction, // Use cancellation direction as primary
|
|
2063
|
-
};
|
|
2064
|
-
}
|
|
2065
|
-
|
|
2066
|
-
trends.push(trend);
|
|
2067
|
-
previousCancellationRate = cancellationRate;
|
|
2068
|
-
previousNoShowRate = noShowRate;
|
|
2069
|
-
});
|
|
2070
|
-
|
|
2071
|
-
return trends;
|
|
2072
|
-
}
|
|
2073
|
-
|
|
2074
|
-
// ==========================================
|
|
2075
|
-
// Review Analytics Methods
|
|
2076
|
-
// ==========================================
|
|
2077
|
-
|
|
2078
|
-
/**
|
|
2079
|
-
* Get review metrics for a specific entity (practitioner, procedure, etc.)
|
|
2080
|
-
*/
|
|
2081
|
-
async getReviewMetricsByEntity(
|
|
2082
|
-
entityType: 'practitioner' | 'procedure' | 'category' | 'subcategory' | 'technology',
|
|
2083
|
-
entityId: string,
|
|
2084
|
-
dateRange?: AnalyticsDateRange,
|
|
2085
|
-
filters?: AnalyticsFilters
|
|
2086
|
-
): Promise<ReviewAnalyticsMetrics | null> {
|
|
2087
|
-
return this.reviewAnalyticsService.getReviewMetricsByEntity(entityType, entityId, dateRange, filters);
|
|
2088
|
-
}
|
|
2089
|
-
|
|
2090
|
-
/**
|
|
2091
|
-
* Get review metrics for multiple entities (grouped)
|
|
2092
|
-
*/
|
|
2093
|
-
async getReviewMetricsByEntities(
|
|
2094
|
-
entityType: 'practitioner' | 'procedure' | 'category' | 'subcategory' | 'technology',
|
|
2095
|
-
dateRange?: AnalyticsDateRange,
|
|
2096
|
-
filters?: AnalyticsFilters
|
|
2097
|
-
): Promise<ReviewAnalyticsMetrics[]> {
|
|
2098
|
-
return this.reviewAnalyticsService.getReviewMetricsByEntities(entityType, dateRange, filters);
|
|
2099
|
-
}
|
|
2100
|
-
|
|
2101
|
-
/**
|
|
2102
|
-
* Get overall review averages for comparison
|
|
2103
|
-
*/
|
|
2104
|
-
async getOverallReviewAverages(
|
|
2105
|
-
dateRange?: AnalyticsDateRange,
|
|
2106
|
-
filters?: AnalyticsFilters
|
|
2107
|
-
): Promise<OverallReviewAverages> {
|
|
2108
|
-
return this.reviewAnalyticsService.getOverallReviewAverages(dateRange, filters);
|
|
2109
|
-
}
|
|
2110
|
-
|
|
2111
|
-
/**
|
|
2112
|
-
* Get review details for a specific entity
|
|
2113
|
-
*/
|
|
2114
|
-
async getReviewDetails(
|
|
2115
|
-
entityType: 'practitioner' | 'procedure',
|
|
2116
|
-
entityId: string,
|
|
2117
|
-
dateRange?: AnalyticsDateRange,
|
|
2118
|
-
filters?: AnalyticsFilters
|
|
2119
|
-
): Promise<ReviewDetail[]> {
|
|
2120
|
-
return this.reviewAnalyticsService.getReviewDetails(entityType, entityId, dateRange, filters);
|
|
2121
|
-
}
|
|
2122
|
-
|
|
2123
|
-
/**
|
|
2124
|
-
* Calculate review trends over time
|
|
2125
|
-
* Groups reviews by period and calculates rating and recommendation metrics
|
|
2126
|
-
*
|
|
2127
|
-
* @param dateRange - Date range for trend analysis
|
|
2128
|
-
* @param period - Period type (week, month, quarter, year)
|
|
2129
|
-
* @param filters - Optional filters for clinic, practitioner, procedure
|
|
2130
|
-
* @param entityType - Optional entity type to group trends by
|
|
2131
|
-
* @returns Array of review trends with percentage changes
|
|
2132
|
-
*/
|
|
2133
|
-
async getReviewTrends(
|
|
2134
|
-
dateRange: AnalyticsDateRange,
|
|
2135
|
-
period: TrendPeriod,
|
|
2136
|
-
filters?: AnalyticsFilters,
|
|
2137
|
-
entityType?: 'practitioner' | 'procedure' | 'technology'
|
|
2138
|
-
): Promise<ReviewTrend[]> {
|
|
2139
|
-
return this.reviewAnalyticsService.getReviewTrends(dateRange, period, filters, entityType);
|
|
2140
|
-
}
|
|
2141
|
-
}
|
|
2142
|
-
|
|
1
|
+
import { Firestore, collection, query, where, getDocs, Timestamp } from 'firebase/firestore';
|
|
2
|
+
import { Auth } from 'firebase/auth';
|
|
3
|
+
import { FirebaseApp } from 'firebase/app';
|
|
4
|
+
import { BaseService } from '../base.service';
|
|
5
|
+
import { Appointment, AppointmentStatus, APPOINTMENTS_COLLECTION, PaymentStatus } from '../../types/appointment';
|
|
6
|
+
import { AppointmentService } from '../appointment/appointment.service';
|
|
7
|
+
import {
|
|
8
|
+
PractitionerAnalytics,
|
|
9
|
+
ProcedureAnalytics,
|
|
10
|
+
TimeEfficiencyMetrics,
|
|
11
|
+
CancellationMetrics,
|
|
12
|
+
NoShowMetrics,
|
|
13
|
+
RevenueMetrics,
|
|
14
|
+
RevenueTrend,
|
|
15
|
+
DurationTrend,
|
|
16
|
+
AppointmentTrend,
|
|
17
|
+
CancellationRateTrend,
|
|
18
|
+
ProductUsageMetrics,
|
|
19
|
+
ProductRevenueMetrics,
|
|
20
|
+
ProductUsageByProcedure,
|
|
21
|
+
PatientAnalytics,
|
|
22
|
+
PatientLifetimeValueMetrics,
|
|
23
|
+
PatientRetentionMetrics,
|
|
24
|
+
CostPerPatientMetrics,
|
|
25
|
+
PaymentStatusBreakdown,
|
|
26
|
+
ClinicAnalytics,
|
|
27
|
+
ClinicComparisonMetrics,
|
|
28
|
+
ProcedurePopularity,
|
|
29
|
+
ProcedureProfitability,
|
|
30
|
+
CancellationReasonStats,
|
|
31
|
+
DashboardAnalytics,
|
|
32
|
+
AnalyticsDateRange,
|
|
33
|
+
AnalyticsFilters,
|
|
34
|
+
GroupingPeriod,
|
|
35
|
+
TrendPeriod,
|
|
36
|
+
EntityType,
|
|
37
|
+
} from '../../types/analytics';
|
|
38
|
+
|
|
39
|
+
// Import utility functions
|
|
40
|
+
import { calculateAppointmentCost, calculateTotalRevenue, extractProductUsage } from './utils/cost-calculation.utils';
|
|
41
|
+
import {
|
|
42
|
+
calculateTimeEfficiency,
|
|
43
|
+
calculateAverageTimeMetrics,
|
|
44
|
+
calculateEfficiencyDistribution,
|
|
45
|
+
calculateCancellationLeadTime,
|
|
46
|
+
} from './utils/time-calculation.utils';
|
|
47
|
+
import {
|
|
48
|
+
filterByDateRange,
|
|
49
|
+
filterAppointments,
|
|
50
|
+
getCompletedAppointments,
|
|
51
|
+
getCanceledAppointments,
|
|
52
|
+
getNoShowAppointments,
|
|
53
|
+
getActiveAppointments,
|
|
54
|
+
calculatePercentage,
|
|
55
|
+
} from './utils/appointment-filtering.utils';
|
|
56
|
+
import {
|
|
57
|
+
readStoredPractitionerAnalytics,
|
|
58
|
+
readStoredProcedureAnalytics,
|
|
59
|
+
readStoredClinicAnalytics,
|
|
60
|
+
readStoredDashboardAnalytics,
|
|
61
|
+
readStoredTimeEfficiencyMetrics,
|
|
62
|
+
readStoredRevenueMetrics,
|
|
63
|
+
readStoredCancellationMetrics,
|
|
64
|
+
readStoredNoShowMetrics,
|
|
65
|
+
} from './utils/stored-analytics.utils';
|
|
66
|
+
import {
|
|
67
|
+
calculateGroupedRevenueMetrics,
|
|
68
|
+
calculateGroupedProductUsageMetrics,
|
|
69
|
+
calculateGroupedTimeEfficiencyMetrics,
|
|
70
|
+
calculateGroupedPatientBehaviorMetrics,
|
|
71
|
+
} from './utils/grouping.utils';
|
|
72
|
+
import {
|
|
73
|
+
groupAppointmentsByPeriod,
|
|
74
|
+
generatePeriods,
|
|
75
|
+
getTrendChange,
|
|
76
|
+
type TrendPeriod as TrendPeriodType,
|
|
77
|
+
} from './utils/trend-calculation.utils';
|
|
78
|
+
import { ReadStoredAnalyticsOptions, AnalyticsPeriod } from '../../types/analytics';
|
|
79
|
+
import {
|
|
80
|
+
GroupedRevenueMetrics,
|
|
81
|
+
GroupedProductUsageMetrics,
|
|
82
|
+
GroupedTimeEfficiencyMetrics,
|
|
83
|
+
GroupedPatientBehaviorMetrics,
|
|
84
|
+
} from '../../types/analytics/grouped-analytics.types';
|
|
85
|
+
import { ReviewAnalyticsService, ReviewAnalyticsMetrics, OverallReviewAverages, ReviewDetail } from './review-analytics.service';
|
|
86
|
+
import { ReviewTrend } from '../../types/analytics';
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* AnalyticsService provides comprehensive financial and analytical intelligence
|
|
90
|
+
* for the Clinic Admin app, including metrics about doctors, procedures,
|
|
91
|
+
* appointments, patients, products, and clinic operations.
|
|
92
|
+
*/
|
|
93
|
+
export class AnalyticsService extends BaseService {
|
|
94
|
+
private appointmentService: AppointmentService;
|
|
95
|
+
private reviewAnalyticsService: ReviewAnalyticsService;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Creates a new AnalyticsService instance.
|
|
99
|
+
*
|
|
100
|
+
* @param db Firestore instance
|
|
101
|
+
* @param auth Firebase Auth instance
|
|
102
|
+
* @param app Firebase App instance
|
|
103
|
+
* @param appointmentService Appointment service instance for querying appointments
|
|
104
|
+
*/
|
|
105
|
+
constructor(db: Firestore, auth: Auth, app: FirebaseApp, appointmentService: AppointmentService) {
|
|
106
|
+
super(db, auth, app);
|
|
107
|
+
this.appointmentService = appointmentService;
|
|
108
|
+
this.reviewAnalyticsService = new ReviewAnalyticsService(db, auth, app, appointmentService);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Fetches appointments with optional filters
|
|
113
|
+
*
|
|
114
|
+
* @param filters - Optional filters
|
|
115
|
+
* @param dateRange - Optional date range
|
|
116
|
+
* @returns Array of appointments
|
|
117
|
+
*/
|
|
118
|
+
private async fetchAppointments(
|
|
119
|
+
filters?: AnalyticsFilters,
|
|
120
|
+
dateRange?: AnalyticsDateRange,
|
|
121
|
+
): Promise<Appointment[]> {
|
|
122
|
+
try {
|
|
123
|
+
// Build query constraints
|
|
124
|
+
const constraints: any[] = [];
|
|
125
|
+
|
|
126
|
+
if (filters?.clinicBranchId) {
|
|
127
|
+
constraints.push(where('clinicBranchId', '==', filters.clinicBranchId));
|
|
128
|
+
}
|
|
129
|
+
if (filters?.practitionerId) {
|
|
130
|
+
constraints.push(where('practitionerId', '==', filters.practitionerId));
|
|
131
|
+
}
|
|
132
|
+
if (filters?.procedureId) {
|
|
133
|
+
constraints.push(where('procedureId', '==', filters.procedureId));
|
|
134
|
+
}
|
|
135
|
+
if (filters?.patientId) {
|
|
136
|
+
constraints.push(where('patientId', '==', filters.patientId));
|
|
137
|
+
}
|
|
138
|
+
if (dateRange) {
|
|
139
|
+
constraints.push(where('appointmentStartTime', '>=', Timestamp.fromDate(dateRange.start)));
|
|
140
|
+
constraints.push(where('appointmentStartTime', '<=', Timestamp.fromDate(dateRange.end)));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Use AppointmentService to search appointments
|
|
144
|
+
const searchParams: any = {};
|
|
145
|
+
if (filters?.clinicBranchId) searchParams.clinicBranchId = filters.clinicBranchId;
|
|
146
|
+
if (filters?.practitionerId) searchParams.practitionerId = filters.practitionerId;
|
|
147
|
+
if (filters?.procedureId) searchParams.procedureId = filters.procedureId;
|
|
148
|
+
if (filters?.patientId) searchParams.patientId = filters.patientId;
|
|
149
|
+
if (dateRange) {
|
|
150
|
+
searchParams.startDate = dateRange.start;
|
|
151
|
+
searchParams.endDate = dateRange.end;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const result = await this.appointmentService.searchAppointments(searchParams);
|
|
155
|
+
|
|
156
|
+
return result.appointments;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
console.error('[AnalyticsService] Error fetching appointments:', error);
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ==========================================
|
|
164
|
+
// Practitioner Analytics
|
|
165
|
+
// ==========================================
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get practitioner performance metrics
|
|
169
|
+
* First checks for stored analytics, then calculates if not available or stale
|
|
170
|
+
*
|
|
171
|
+
* @param practitionerId - ID of the practitioner
|
|
172
|
+
* @param dateRange - Optional date range filter
|
|
173
|
+
* @param options - Options for reading stored analytics
|
|
174
|
+
* @returns Practitioner analytics object
|
|
175
|
+
*/
|
|
176
|
+
async getPractitionerAnalytics(
|
|
177
|
+
practitionerId: string,
|
|
178
|
+
dateRange?: AnalyticsDateRange,
|
|
179
|
+
options?: ReadStoredAnalyticsOptions,
|
|
180
|
+
): Promise<PractitionerAnalytics> {
|
|
181
|
+
// Try to read from stored analytics first
|
|
182
|
+
if (dateRange && options?.useCache !== false) {
|
|
183
|
+
// Determine period from date range
|
|
184
|
+
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
185
|
+
const clinicBranchId = options?.clinicBranchId; // Would need to be passed or determined
|
|
186
|
+
|
|
187
|
+
if (clinicBranchId) {
|
|
188
|
+
const stored = await readStoredPractitionerAnalytics(
|
|
189
|
+
this.db,
|
|
190
|
+
clinicBranchId,
|
|
191
|
+
practitionerId,
|
|
192
|
+
{ ...options, period },
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
if (stored) {
|
|
196
|
+
// Return stored data (without metadata)
|
|
197
|
+
const { metadata, ...analytics } = stored;
|
|
198
|
+
return analytics;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Fall back to calculation
|
|
204
|
+
const appointments = await this.fetchAppointments({ practitionerId }, dateRange);
|
|
205
|
+
|
|
206
|
+
const completed = getCompletedAppointments(appointments);
|
|
207
|
+
const canceled = getCanceledAppointments(appointments);
|
|
208
|
+
const noShow = getNoShowAppointments(appointments);
|
|
209
|
+
const pending = filterAppointments(appointments, { practitionerId }).filter(
|
|
210
|
+
a => a.status === AppointmentStatus.PENDING,
|
|
211
|
+
);
|
|
212
|
+
const confirmed = filterAppointments(appointments, { practitionerId }).filter(
|
|
213
|
+
a => a.status === AppointmentStatus.CONFIRMED,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
217
|
+
const timeMetrics = calculateAverageTimeMetrics(completed);
|
|
218
|
+
|
|
219
|
+
// Get unique patients
|
|
220
|
+
const uniquePatients = new Set(appointments.map(a => a.patientId));
|
|
221
|
+
const returningPatients = new Set(
|
|
222
|
+
appointments
|
|
223
|
+
.filter(a => {
|
|
224
|
+
const patientAppointments = appointments.filter(ap => ap.patientId === a.patientId);
|
|
225
|
+
return patientAppointments.length > 1;
|
|
226
|
+
})
|
|
227
|
+
.map(a => a.patientId),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Get top procedures
|
|
231
|
+
const procedureMap = new Map<string, { name: string; count: number; revenue: number }>();
|
|
232
|
+
completed.forEach(appointment => {
|
|
233
|
+
const procId = appointment.procedureId;
|
|
234
|
+
const procName = appointment.procedureInfo?.name || 'Unknown';
|
|
235
|
+
const cost = calculateAppointmentCost(appointment).cost;
|
|
236
|
+
|
|
237
|
+
if (procedureMap.has(procId)) {
|
|
238
|
+
const existing = procedureMap.get(procId)!;
|
|
239
|
+
existing.count++;
|
|
240
|
+
existing.revenue += cost;
|
|
241
|
+
} else {
|
|
242
|
+
procedureMap.set(procId, { name: procName, count: 1, revenue: cost });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const topProcedures = Array.from(procedureMap.entries())
|
|
247
|
+
.map(([procedureId, data]) => ({
|
|
248
|
+
procedureId,
|
|
249
|
+
procedureName: data.name,
|
|
250
|
+
count: data.count,
|
|
251
|
+
revenue: data.revenue,
|
|
252
|
+
}))
|
|
253
|
+
.sort((a, b) => b.count - a.count)
|
|
254
|
+
.slice(0, 10);
|
|
255
|
+
|
|
256
|
+
const practitionerName =
|
|
257
|
+
appointments.length > 0
|
|
258
|
+
? appointments[0].practitionerInfo?.name || 'Unknown'
|
|
259
|
+
: 'Unknown';
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
total: appointments.length,
|
|
263
|
+
dateRange,
|
|
264
|
+
practitionerId,
|
|
265
|
+
practitionerName,
|
|
266
|
+
totalAppointments: appointments.length,
|
|
267
|
+
completedAppointments: completed.length,
|
|
268
|
+
canceledAppointments: canceled.length,
|
|
269
|
+
noShowAppointments: noShow.length,
|
|
270
|
+
pendingAppointments: pending.length,
|
|
271
|
+
confirmedAppointments: confirmed.length,
|
|
272
|
+
cancellationRate: calculatePercentage(canceled.length, appointments.length),
|
|
273
|
+
noShowRate: calculatePercentage(noShow.length, appointments.length),
|
|
274
|
+
averageBookedTime: timeMetrics.averageBookedDuration,
|
|
275
|
+
averageActualTime: timeMetrics.averageActualDuration,
|
|
276
|
+
timeEfficiency: timeMetrics.averageEfficiency,
|
|
277
|
+
totalRevenue,
|
|
278
|
+
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
279
|
+
currency,
|
|
280
|
+
topProcedures,
|
|
281
|
+
patientRetentionRate: calculatePercentage(returningPatients.size, uniquePatients.size),
|
|
282
|
+
uniquePatients: uniquePatients.size,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ==========================================
|
|
287
|
+
// Procedure Analytics
|
|
288
|
+
// ==========================================
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Get procedure performance metrics
|
|
292
|
+
* First checks for stored analytics, then calculates if not available or stale
|
|
293
|
+
*
|
|
294
|
+
* @param procedureId - ID of the procedure (optional, if not provided returns all)
|
|
295
|
+
* @param dateRange - Optional date range filter
|
|
296
|
+
* @param options - Options for reading stored analytics
|
|
297
|
+
* @returns Procedure analytics object or array
|
|
298
|
+
*/
|
|
299
|
+
async getProcedureAnalytics(
|
|
300
|
+
procedureId?: string,
|
|
301
|
+
dateRange?: AnalyticsDateRange,
|
|
302
|
+
options?: ReadStoredAnalyticsOptions,
|
|
303
|
+
): Promise<ProcedureAnalytics | ProcedureAnalytics[]> {
|
|
304
|
+
// Try to read from stored analytics first (only for single procedure)
|
|
305
|
+
if (procedureId && dateRange && options?.useCache !== false) {
|
|
306
|
+
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
307
|
+
const clinicBranchId = options?.clinicBranchId;
|
|
308
|
+
|
|
309
|
+
if (clinicBranchId) {
|
|
310
|
+
const stored = await readStoredProcedureAnalytics(
|
|
311
|
+
this.db,
|
|
312
|
+
clinicBranchId,
|
|
313
|
+
procedureId,
|
|
314
|
+
{ ...options, period },
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
if (stored) {
|
|
318
|
+
// Return stored data (without metadata)
|
|
319
|
+
const { metadata, ...analytics } = stored;
|
|
320
|
+
return analytics;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Fall back to calculation
|
|
326
|
+
const appointments = await this.fetchAppointments(procedureId ? { procedureId } : undefined, dateRange);
|
|
327
|
+
|
|
328
|
+
if (procedureId) {
|
|
329
|
+
return this.calculateProcedureAnalytics(appointments, procedureId);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Group by procedure
|
|
333
|
+
const procedureMap = new Map<string, Appointment[]>();
|
|
334
|
+
appointments.forEach(appointment => {
|
|
335
|
+
const procId = appointment.procedureId;
|
|
336
|
+
if (!procedureMap.has(procId)) {
|
|
337
|
+
procedureMap.set(procId, []);
|
|
338
|
+
}
|
|
339
|
+
procedureMap.get(procId)!.push(appointment);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
return Array.from(procedureMap.entries()).map(([procId, procAppointments]) =>
|
|
343
|
+
this.calculateProcedureAnalytics(procAppointments, procId),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Calculate analytics for a specific procedure
|
|
349
|
+
*
|
|
350
|
+
* @param appointments - Appointments for the procedure
|
|
351
|
+
* @param procedureId - Procedure ID
|
|
352
|
+
* @returns Procedure analytics
|
|
353
|
+
*/
|
|
354
|
+
private calculateProcedureAnalytics(
|
|
355
|
+
appointments: Appointment[],
|
|
356
|
+
procedureId: string,
|
|
357
|
+
): ProcedureAnalytics {
|
|
358
|
+
const completed = getCompletedAppointments(appointments);
|
|
359
|
+
const canceled = getCanceledAppointments(appointments);
|
|
360
|
+
const noShow = getNoShowAppointments(appointments);
|
|
361
|
+
|
|
362
|
+
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
363
|
+
const timeMetrics = calculateAverageTimeMetrics(completed);
|
|
364
|
+
|
|
365
|
+
const firstAppointment = appointments[0];
|
|
366
|
+
const procedureInfo = firstAppointment?.procedureExtendedInfo || firstAppointment?.procedureInfo;
|
|
367
|
+
|
|
368
|
+
// Extract product usage
|
|
369
|
+
const productMap = new Map<
|
|
370
|
+
string,
|
|
371
|
+
{ name: string; brandName: string; quantity: number; revenue: number; usageCount: number }
|
|
372
|
+
>();
|
|
373
|
+
|
|
374
|
+
completed.forEach(appointment => {
|
|
375
|
+
const products = extractProductUsage(appointment);
|
|
376
|
+
products.forEach(product => {
|
|
377
|
+
if (productMap.has(product.productId)) {
|
|
378
|
+
const existing = productMap.get(product.productId)!;
|
|
379
|
+
existing.quantity += product.quantity;
|
|
380
|
+
existing.revenue += product.subtotal;
|
|
381
|
+
existing.usageCount++;
|
|
382
|
+
} else {
|
|
383
|
+
productMap.set(product.productId, {
|
|
384
|
+
name: product.productName,
|
|
385
|
+
brandName: product.brandName,
|
|
386
|
+
quantity: product.quantity,
|
|
387
|
+
revenue: product.subtotal,
|
|
388
|
+
usageCount: 1,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const productUsage = Array.from(productMap.entries()).map(([productId, data]) => ({
|
|
395
|
+
productId,
|
|
396
|
+
productName: data.name,
|
|
397
|
+
brandName: data.brandName,
|
|
398
|
+
totalQuantity: data.quantity,
|
|
399
|
+
totalRevenue: data.revenue,
|
|
400
|
+
usageCount: data.usageCount,
|
|
401
|
+
}));
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
total: appointments.length,
|
|
405
|
+
procedureId,
|
|
406
|
+
procedureName: procedureInfo?.name || 'Unknown',
|
|
407
|
+
procedureFamily: procedureInfo?.procedureFamily || '',
|
|
408
|
+
categoryName: procedureInfo?.procedureCategoryName || '',
|
|
409
|
+
subcategoryName: procedureInfo?.procedureSubCategoryName || '',
|
|
410
|
+
technologyName: procedureInfo?.procedureTechnologyName || '',
|
|
411
|
+
totalAppointments: appointments.length,
|
|
412
|
+
completedAppointments: completed.length,
|
|
413
|
+
canceledAppointments: canceled.length,
|
|
414
|
+
noShowAppointments: noShow.length,
|
|
415
|
+
cancellationRate: calculatePercentage(canceled.length, appointments.length),
|
|
416
|
+
noShowRate: calculatePercentage(noShow.length, appointments.length),
|
|
417
|
+
averageCost: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
418
|
+
totalRevenue,
|
|
419
|
+
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
420
|
+
currency,
|
|
421
|
+
averageBookedDuration: timeMetrics.averageBookedDuration,
|
|
422
|
+
averageActualDuration: timeMetrics.averageActualDuration,
|
|
423
|
+
productUsage,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Get procedure popularity metrics
|
|
429
|
+
*
|
|
430
|
+
* @param dateRange - Optional date range filter
|
|
431
|
+
* @param limit - Number of top procedures to return
|
|
432
|
+
* @returns Array of procedure popularity metrics
|
|
433
|
+
*/
|
|
434
|
+
async getProcedurePopularity(
|
|
435
|
+
dateRange?: AnalyticsDateRange,
|
|
436
|
+
limit: number = 10,
|
|
437
|
+
): Promise<ProcedurePopularity[]> {
|
|
438
|
+
const appointments = await this.fetchAppointments(undefined, dateRange);
|
|
439
|
+
const completed = getCompletedAppointments(appointments);
|
|
440
|
+
|
|
441
|
+
const procedureMap = new Map<string, { name: string; category: string; subcategory: string; technology: string; count: number }>();
|
|
442
|
+
|
|
443
|
+
completed.forEach(appointment => {
|
|
444
|
+
const procId = appointment.procedureId;
|
|
445
|
+
const procInfo = appointment.procedureExtendedInfo || appointment.procedureInfo;
|
|
446
|
+
|
|
447
|
+
if (procedureMap.has(procId)) {
|
|
448
|
+
procedureMap.get(procId)!.count++;
|
|
449
|
+
} else {
|
|
450
|
+
procedureMap.set(procId, {
|
|
451
|
+
name: procInfo?.name || 'Unknown',
|
|
452
|
+
category: procInfo?.procedureCategoryName || '',
|
|
453
|
+
subcategory: procInfo?.procedureSubCategoryName || '',
|
|
454
|
+
technology: procInfo?.procedureTechnologyName || '',
|
|
455
|
+
count: 1,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
return Array.from(procedureMap.entries())
|
|
461
|
+
.map(([procedureId, data]) => ({
|
|
462
|
+
procedureId,
|
|
463
|
+
procedureName: data.name,
|
|
464
|
+
categoryName: data.category,
|
|
465
|
+
subcategoryName: data.subcategory,
|
|
466
|
+
technologyName: data.technology,
|
|
467
|
+
appointmentCount: data.count,
|
|
468
|
+
completedCount: data.count,
|
|
469
|
+
rank: 0, // Will be set after sorting
|
|
470
|
+
}))
|
|
471
|
+
.sort((a, b) => b.appointmentCount - a.appointmentCount)
|
|
472
|
+
.slice(0, limit)
|
|
473
|
+
.map((item, index) => ({ ...item, rank: index + 1 }));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Get procedure profitability metrics
|
|
478
|
+
*
|
|
479
|
+
* @param dateRange - Optional date range filter
|
|
480
|
+
* @param limit - Number of top procedures to return
|
|
481
|
+
* @returns Array of procedure profitability metrics
|
|
482
|
+
*/
|
|
483
|
+
async getProcedureProfitability(
|
|
484
|
+
dateRange?: AnalyticsDateRange,
|
|
485
|
+
limit: number = 10,
|
|
486
|
+
): Promise<ProcedureProfitability[]> {
|
|
487
|
+
const appointments = await this.fetchAppointments(undefined, dateRange);
|
|
488
|
+
const completed = getCompletedAppointments(appointments);
|
|
489
|
+
|
|
490
|
+
const procedureMap = new Map<
|
|
491
|
+
string,
|
|
492
|
+
{
|
|
493
|
+
name: string;
|
|
494
|
+
category: string;
|
|
495
|
+
subcategory: string;
|
|
496
|
+
technology: string;
|
|
497
|
+
revenue: number;
|
|
498
|
+
count: number;
|
|
499
|
+
}
|
|
500
|
+
>();
|
|
501
|
+
|
|
502
|
+
completed.forEach(appointment => {
|
|
503
|
+
const procId = appointment.procedureId;
|
|
504
|
+
const procInfo = appointment.procedureExtendedInfo || appointment.procedureInfo;
|
|
505
|
+
const cost = calculateAppointmentCost(appointment).cost;
|
|
506
|
+
|
|
507
|
+
if (procedureMap.has(procId)) {
|
|
508
|
+
const existing = procedureMap.get(procId)!;
|
|
509
|
+
existing.revenue += cost;
|
|
510
|
+
existing.count++;
|
|
511
|
+
} else {
|
|
512
|
+
procedureMap.set(procId, {
|
|
513
|
+
name: procInfo?.name || 'Unknown',
|
|
514
|
+
category: procInfo?.procedureCategoryName || '',
|
|
515
|
+
subcategory: procInfo?.procedureSubCategoryName || '',
|
|
516
|
+
technology: procInfo?.procedureTechnologyName || '',
|
|
517
|
+
revenue: cost,
|
|
518
|
+
count: 1,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
return Array.from(procedureMap.entries())
|
|
524
|
+
.map(([procedureId, data]) => ({
|
|
525
|
+
procedureId,
|
|
526
|
+
procedureName: data.name,
|
|
527
|
+
categoryName: data.category,
|
|
528
|
+
subcategoryName: data.subcategory,
|
|
529
|
+
technologyName: data.technology,
|
|
530
|
+
totalRevenue: data.revenue,
|
|
531
|
+
averageRevenue: data.count > 0 ? data.revenue / data.count : 0,
|
|
532
|
+
appointmentCount: data.count,
|
|
533
|
+
rank: 0, // Will be set after sorting
|
|
534
|
+
}))
|
|
535
|
+
.sort((a, b) => b.totalRevenue - a.totalRevenue)
|
|
536
|
+
.slice(0, limit)
|
|
537
|
+
.map((item, index) => ({ ...item, rank: index + 1 }));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ==========================================
|
|
541
|
+
// Time Efficiency Analytics
|
|
542
|
+
// ==========================================
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Get time efficiency metrics grouped by clinic, practitioner, procedure, patient, or technology
|
|
546
|
+
*
|
|
547
|
+
* @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient' | 'technology'
|
|
548
|
+
* @param dateRange - Optional date range filter
|
|
549
|
+
* @param filters - Optional additional filters
|
|
550
|
+
* @returns Grouped time efficiency metrics
|
|
551
|
+
*/
|
|
552
|
+
async getTimeEfficiencyMetricsByEntity(
|
|
553
|
+
groupBy: EntityType,
|
|
554
|
+
dateRange?: AnalyticsDateRange,
|
|
555
|
+
filters?: AnalyticsFilters,
|
|
556
|
+
): Promise<GroupedTimeEfficiencyMetrics[]> {
|
|
557
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
558
|
+
return calculateGroupedTimeEfficiencyMetrics(appointments, groupBy);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Get time efficiency metrics for appointments
|
|
563
|
+
* First checks for stored analytics, then calculates if not available or stale
|
|
564
|
+
*
|
|
565
|
+
* @param filters - Optional filters
|
|
566
|
+
* @param dateRange - Optional date range filter
|
|
567
|
+
* @param options - Options for reading stored analytics
|
|
568
|
+
* @returns Time efficiency metrics
|
|
569
|
+
*/
|
|
570
|
+
async getTimeEfficiencyMetrics(
|
|
571
|
+
filters?: AnalyticsFilters,
|
|
572
|
+
dateRange?: AnalyticsDateRange,
|
|
573
|
+
options?: ReadStoredAnalyticsOptions,
|
|
574
|
+
): Promise<TimeEfficiencyMetrics> {
|
|
575
|
+
// Try to read from stored analytics first
|
|
576
|
+
if (filters?.clinicBranchId && dateRange && options?.useCache !== false) {
|
|
577
|
+
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
578
|
+
const stored = await readStoredTimeEfficiencyMetrics(
|
|
579
|
+
this.db,
|
|
580
|
+
filters.clinicBranchId,
|
|
581
|
+
{ ...options, period },
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
if (stored) {
|
|
585
|
+
// Return stored data (without metadata)
|
|
586
|
+
const { metadata, ...metrics } = stored;
|
|
587
|
+
return metrics;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Fall back to calculation
|
|
592
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
593
|
+
const completed = getCompletedAppointments(appointments);
|
|
594
|
+
|
|
595
|
+
const timeMetrics = calculateAverageTimeMetrics(completed);
|
|
596
|
+
const efficiencyDistribution = calculateEfficiencyDistribution(completed);
|
|
597
|
+
|
|
598
|
+
return {
|
|
599
|
+
totalAppointments: completed.length,
|
|
600
|
+
appointmentsWithActualTime: timeMetrics.appointmentsWithActualTime,
|
|
601
|
+
averageBookedDuration: timeMetrics.averageBookedDuration,
|
|
602
|
+
averageActualDuration: timeMetrics.averageActualDuration,
|
|
603
|
+
averageEfficiency: timeMetrics.averageEfficiency,
|
|
604
|
+
totalOverrun: timeMetrics.totalOverrun,
|
|
605
|
+
totalUnderutilization: timeMetrics.totalUnderutilization,
|
|
606
|
+
averageOverrun: timeMetrics.averageOverrun,
|
|
607
|
+
averageUnderutilization: timeMetrics.averageUnderutilization,
|
|
608
|
+
efficiencyDistribution,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ==========================================
|
|
613
|
+
// Cancellation & No-Show Analytics
|
|
614
|
+
// ==========================================
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Get cancellation metrics
|
|
618
|
+
* First checks for stored analytics when grouping by clinic, then calculates if not available or stale
|
|
619
|
+
*
|
|
620
|
+
* @param groupBy - Group by 'clinic' | 'practitioner' | 'patient' | 'procedure' | 'technology'
|
|
621
|
+
* @param dateRange - Optional date range filter
|
|
622
|
+
* @param options - Options for reading stored analytics (requires clinicBranchId for cache)
|
|
623
|
+
* @returns Cancellation metrics grouped by specified entity
|
|
624
|
+
*/
|
|
625
|
+
async getCancellationMetrics(
|
|
626
|
+
groupBy: EntityType,
|
|
627
|
+
dateRange?: AnalyticsDateRange,
|
|
628
|
+
options?: ReadStoredAnalyticsOptions,
|
|
629
|
+
): Promise<CancellationMetrics | CancellationMetrics[]> {
|
|
630
|
+
// Try to read from stored analytics first (only for clinic-level grouping)
|
|
631
|
+
if (groupBy === 'clinic' && dateRange && options?.useCache !== false && options?.clinicBranchId) {
|
|
632
|
+
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
633
|
+
const stored = await readStoredCancellationMetrics(
|
|
634
|
+
this.db,
|
|
635
|
+
options.clinicBranchId,
|
|
636
|
+
'clinic',
|
|
637
|
+
{ ...options, period },
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
if (stored) {
|
|
641
|
+
// Return stored data (without metadata)
|
|
642
|
+
const { metadata, ...metrics } = stored;
|
|
643
|
+
return metrics;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Fall back to calculation
|
|
648
|
+
const appointments = await this.fetchAppointments(undefined, dateRange);
|
|
649
|
+
const canceled = getCanceledAppointments(appointments);
|
|
650
|
+
|
|
651
|
+
if (groupBy === 'clinic') {
|
|
652
|
+
return this.groupCancellationsByClinic(canceled, appointments);
|
|
653
|
+
} else if (groupBy === 'practitioner') {
|
|
654
|
+
return this.groupCancellationsByPractitioner(canceled, appointments);
|
|
655
|
+
} else if (groupBy === 'patient') {
|
|
656
|
+
return this.groupCancellationsByPatient(canceled, appointments);
|
|
657
|
+
} else if (groupBy === 'technology') {
|
|
658
|
+
return this.groupCancellationsByTechnology(canceled, appointments);
|
|
659
|
+
} else {
|
|
660
|
+
return this.groupCancellationsByProcedure(canceled, appointments);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Group cancellations by clinic
|
|
666
|
+
*/
|
|
667
|
+
private groupCancellationsByClinic(
|
|
668
|
+
canceled: Appointment[],
|
|
669
|
+
allAppointments: Appointment[],
|
|
670
|
+
): CancellationMetrics[] {
|
|
671
|
+
const clinicMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[] }>();
|
|
672
|
+
|
|
673
|
+
allAppointments.forEach(appointment => {
|
|
674
|
+
const clinicId = appointment.clinicBranchId;
|
|
675
|
+
const clinicName = appointment.clinicInfo?.name || 'Unknown';
|
|
676
|
+
|
|
677
|
+
if (!clinicMap.has(clinicId)) {
|
|
678
|
+
clinicMap.set(clinicId, { name: clinicName, canceled: [], all: [] });
|
|
679
|
+
}
|
|
680
|
+
clinicMap.get(clinicId)!.all.push(appointment);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
canceled.forEach(appointment => {
|
|
684
|
+
const clinicId = appointment.clinicBranchId;
|
|
685
|
+
if (clinicMap.has(clinicId)) {
|
|
686
|
+
clinicMap.get(clinicId)!.canceled.push(appointment);
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
return Array.from(clinicMap.entries()).map(([clinicId, data]) =>
|
|
691
|
+
this.calculateCancellationMetrics(clinicId, data.name, 'clinic', data.canceled, data.all),
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Group cancellations by practitioner
|
|
697
|
+
*/
|
|
698
|
+
private groupCancellationsByPractitioner(
|
|
699
|
+
canceled: Appointment[],
|
|
700
|
+
allAppointments: Appointment[],
|
|
701
|
+
): CancellationMetrics[] {
|
|
702
|
+
const practitionerMap = new Map<
|
|
703
|
+
string,
|
|
704
|
+
{ name: string; canceled: Appointment[]; all: Appointment[] }
|
|
705
|
+
>();
|
|
706
|
+
|
|
707
|
+
allAppointments.forEach(appointment => {
|
|
708
|
+
const practitionerId = appointment.practitionerId;
|
|
709
|
+
const practitionerName = appointment.practitionerInfo?.name || 'Unknown';
|
|
710
|
+
|
|
711
|
+
if (!practitionerMap.has(practitionerId)) {
|
|
712
|
+
practitionerMap.set(practitionerId, { name: practitionerName, canceled: [], all: [] });
|
|
713
|
+
}
|
|
714
|
+
practitionerMap.get(practitionerId)!.all.push(appointment);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
canceled.forEach(appointment => {
|
|
718
|
+
const practitionerId = appointment.practitionerId;
|
|
719
|
+
if (practitionerMap.has(practitionerId)) {
|
|
720
|
+
practitionerMap.get(practitionerId)!.canceled.push(appointment);
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
return Array.from(practitionerMap.entries()).map(([practitionerId, data]) =>
|
|
725
|
+
this.calculateCancellationMetrics(
|
|
726
|
+
practitionerId,
|
|
727
|
+
data.name,
|
|
728
|
+
'practitioner',
|
|
729
|
+
data.canceled,
|
|
730
|
+
data.all,
|
|
731
|
+
),
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Group cancellations by patient
|
|
737
|
+
*/
|
|
738
|
+
private groupCancellationsByPatient(
|
|
739
|
+
canceled: Appointment[],
|
|
740
|
+
allAppointments: Appointment[],
|
|
741
|
+
): CancellationMetrics[] {
|
|
742
|
+
const patientMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[] }>();
|
|
743
|
+
|
|
744
|
+
allAppointments.forEach(appointment => {
|
|
745
|
+
const patientId = appointment.patientId;
|
|
746
|
+
const patientName = appointment.patientInfo?.fullName || 'Unknown';
|
|
747
|
+
|
|
748
|
+
if (!patientMap.has(patientId)) {
|
|
749
|
+
patientMap.set(patientId, { name: patientName, canceled: [], all: [] });
|
|
750
|
+
}
|
|
751
|
+
patientMap.get(patientId)!.all.push(appointment);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
canceled.forEach(appointment => {
|
|
755
|
+
const patientId = appointment.patientId;
|
|
756
|
+
if (patientMap.has(patientId)) {
|
|
757
|
+
patientMap.get(patientId)!.canceled.push(appointment);
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
return Array.from(patientMap.entries()).map(([patientId, data]) =>
|
|
762
|
+
this.calculateCancellationMetrics(patientId, data.name, 'patient', data.canceled, data.all),
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Group cancellations by procedure
|
|
768
|
+
*/
|
|
769
|
+
private groupCancellationsByProcedure(
|
|
770
|
+
canceled: Appointment[],
|
|
771
|
+
allAppointments: Appointment[],
|
|
772
|
+
): CancellationMetrics[] {
|
|
773
|
+
const procedureMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[]; practitionerId?: string; practitionerName?: string }>();
|
|
774
|
+
|
|
775
|
+
allAppointments.forEach(appointment => {
|
|
776
|
+
const procedureId = appointment.procedureId;
|
|
777
|
+
const procedureName = appointment.procedureInfo?.name || 'Unknown';
|
|
778
|
+
|
|
779
|
+
if (!procedureMap.has(procedureId)) {
|
|
780
|
+
procedureMap.set(procedureId, {
|
|
781
|
+
name: procedureName,
|
|
782
|
+
canceled: [],
|
|
783
|
+
all: [],
|
|
784
|
+
practitionerId: appointment.practitionerId,
|
|
785
|
+
practitionerName: appointment.practitionerInfo?.name,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
procedureMap.get(procedureId)!.all.push(appointment);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
canceled.forEach(appointment => {
|
|
792
|
+
const procedureId = appointment.procedureId;
|
|
793
|
+
if (procedureMap.has(procedureId)) {
|
|
794
|
+
procedureMap.get(procedureId)!.canceled.push(appointment);
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
return Array.from(procedureMap.entries()).map(([procedureId, data]) => {
|
|
799
|
+
const metrics = this.calculateCancellationMetrics(
|
|
800
|
+
procedureId,
|
|
801
|
+
data.name,
|
|
802
|
+
'procedure',
|
|
803
|
+
data.canceled,
|
|
804
|
+
data.all,
|
|
805
|
+
);
|
|
806
|
+
return {
|
|
807
|
+
...metrics,
|
|
808
|
+
...(data.practitionerId && { practitionerId: data.practitionerId }),
|
|
809
|
+
...(data.practitionerName && { practitionerName: data.practitionerName }),
|
|
810
|
+
};
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Group cancellations by technology
|
|
816
|
+
* Aggregates all procedures using the same technology across all doctors
|
|
817
|
+
*/
|
|
818
|
+
private groupCancellationsByTechnology(
|
|
819
|
+
canceled: Appointment[],
|
|
820
|
+
allAppointments: Appointment[],
|
|
821
|
+
): CancellationMetrics[] {
|
|
822
|
+
const technologyMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[] }>();
|
|
823
|
+
|
|
824
|
+
allAppointments.forEach(appointment => {
|
|
825
|
+
const technologyId =
|
|
826
|
+
appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
|
|
827
|
+
const technologyName =
|
|
828
|
+
appointment.procedureExtendedInfo?.procedureTechnologyName ||
|
|
829
|
+
appointment.procedureInfo?.technologyName ||
|
|
830
|
+
'Unknown';
|
|
831
|
+
|
|
832
|
+
if (!technologyMap.has(technologyId)) {
|
|
833
|
+
technologyMap.set(technologyId, { name: technologyName, canceled: [], all: [] });
|
|
834
|
+
}
|
|
835
|
+
technologyMap.get(technologyId)!.all.push(appointment);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
canceled.forEach(appointment => {
|
|
839
|
+
const technologyId =
|
|
840
|
+
appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
|
|
841
|
+
if (technologyMap.has(technologyId)) {
|
|
842
|
+
technologyMap.get(technologyId)!.canceled.push(appointment);
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
return Array.from(technologyMap.entries()).map(([technologyId, data]) =>
|
|
847
|
+
this.calculateCancellationMetrics(
|
|
848
|
+
technologyId,
|
|
849
|
+
data.name,
|
|
850
|
+
'technology',
|
|
851
|
+
data.canceled,
|
|
852
|
+
data.all,
|
|
853
|
+
),
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Calculate cancellation metrics for a specific entity
|
|
859
|
+
*/
|
|
860
|
+
private calculateCancellationMetrics(
|
|
861
|
+
entityId: string,
|
|
862
|
+
entityName: string,
|
|
863
|
+
entityType: EntityType,
|
|
864
|
+
canceled: Appointment[],
|
|
865
|
+
all: Appointment[],
|
|
866
|
+
): CancellationMetrics {
|
|
867
|
+
const canceledByPatient = canceled.filter(
|
|
868
|
+
a => a.status === AppointmentStatus.CANCELED_PATIENT,
|
|
869
|
+
).length;
|
|
870
|
+
const canceledByClinic = canceled.filter(
|
|
871
|
+
a => a.status === AppointmentStatus.CANCELED_CLINIC,
|
|
872
|
+
).length;
|
|
873
|
+
const canceledRescheduled = canceled.filter(
|
|
874
|
+
a => a.status === AppointmentStatus.CANCELED_PATIENT_RESCHEDULED,
|
|
875
|
+
).length;
|
|
876
|
+
|
|
877
|
+
// Calculate average cancellation lead time
|
|
878
|
+
const leadTimes = canceled
|
|
879
|
+
.map(a => calculateCancellationLeadTime(a))
|
|
880
|
+
.filter((lt): lt is number => lt !== null);
|
|
881
|
+
const averageLeadTime =
|
|
882
|
+
leadTimes.length > 0 ? leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length : 0;
|
|
883
|
+
|
|
884
|
+
// Group cancellation reasons
|
|
885
|
+
const reasonMap = new Map<string, number>();
|
|
886
|
+
canceled.forEach(appointment => {
|
|
887
|
+
const reason = appointment.cancellationReason || 'No reason provided';
|
|
888
|
+
reasonMap.set(reason, (reasonMap.get(reason) || 0) + 1);
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
const cancellationReasons = Array.from(reasonMap.entries()).map(([reason, count]) => ({
|
|
892
|
+
reason,
|
|
893
|
+
count,
|
|
894
|
+
percentage: calculatePercentage(count, canceled.length),
|
|
895
|
+
}));
|
|
896
|
+
|
|
897
|
+
return {
|
|
898
|
+
entityId,
|
|
899
|
+
entityName,
|
|
900
|
+
entityType,
|
|
901
|
+
totalAppointments: all.length,
|
|
902
|
+
canceledAppointments: canceled.length,
|
|
903
|
+
cancellationRate: calculatePercentage(canceled.length, all.length),
|
|
904
|
+
canceledByPatient,
|
|
905
|
+
canceledByClinic,
|
|
906
|
+
canceledByPractitioner: 0, // Not tracked in current status enum
|
|
907
|
+
canceledRescheduled,
|
|
908
|
+
averageCancellationLeadTime: Math.round(averageLeadTime * 100) / 100,
|
|
909
|
+
cancellationReasons,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Get no-show metrics
|
|
915
|
+
* First checks for stored analytics when grouping by clinic, then calculates if not available or stale
|
|
916
|
+
*
|
|
917
|
+
* @param groupBy - Group by 'clinic' | 'practitioner' | 'patient' | 'procedure' | 'technology'
|
|
918
|
+
* @param dateRange - Optional date range filter
|
|
919
|
+
* @param options - Options for reading stored analytics (requires clinicBranchId for cache)
|
|
920
|
+
* @returns No-show metrics grouped by specified entity
|
|
921
|
+
*/
|
|
922
|
+
async getNoShowMetrics(
|
|
923
|
+
groupBy: EntityType,
|
|
924
|
+
dateRange?: AnalyticsDateRange,
|
|
925
|
+
options?: ReadStoredAnalyticsOptions,
|
|
926
|
+
): Promise<NoShowMetrics | NoShowMetrics[]> {
|
|
927
|
+
// Try to read from stored analytics first (only for clinic-level grouping)
|
|
928
|
+
if (groupBy === 'clinic' && dateRange && options?.useCache !== false && options?.clinicBranchId) {
|
|
929
|
+
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
930
|
+
const stored = await readStoredNoShowMetrics(
|
|
931
|
+
this.db,
|
|
932
|
+
options.clinicBranchId,
|
|
933
|
+
'clinic',
|
|
934
|
+
{ ...options, period },
|
|
935
|
+
);
|
|
936
|
+
|
|
937
|
+
if (stored) {
|
|
938
|
+
// Return stored data (without metadata)
|
|
939
|
+
const { metadata, ...metrics } = stored;
|
|
940
|
+
return metrics;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Fall back to calculation
|
|
945
|
+
const appointments = await this.fetchAppointments(undefined, dateRange);
|
|
946
|
+
const noShow = getNoShowAppointments(appointments);
|
|
947
|
+
|
|
948
|
+
if (groupBy === 'clinic') {
|
|
949
|
+
return this.groupNoShowsByClinic(noShow, appointments);
|
|
950
|
+
} else if (groupBy === 'practitioner') {
|
|
951
|
+
return this.groupNoShowsByPractitioner(noShow, appointments);
|
|
952
|
+
} else if (groupBy === 'patient') {
|
|
953
|
+
return this.groupNoShowsByPatient(noShow, appointments);
|
|
954
|
+
} else if (groupBy === 'technology') {
|
|
955
|
+
return this.groupNoShowsByTechnology(noShow, appointments);
|
|
956
|
+
} else {
|
|
957
|
+
return this.groupNoShowsByProcedure(noShow, appointments);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Group no-shows by clinic
|
|
963
|
+
*/
|
|
964
|
+
private groupNoShowsByClinic(
|
|
965
|
+
noShow: Appointment[],
|
|
966
|
+
allAppointments: Appointment[],
|
|
967
|
+
): NoShowMetrics[] {
|
|
968
|
+
const clinicMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[] }>();
|
|
969
|
+
|
|
970
|
+
allAppointments.forEach(appointment => {
|
|
971
|
+
const clinicId = appointment.clinicBranchId;
|
|
972
|
+
const clinicName = appointment.clinicInfo?.name || 'Unknown';
|
|
973
|
+
if (!clinicMap.has(clinicId)) {
|
|
974
|
+
clinicMap.set(clinicId, { name: clinicName, noShow: [], all: [] });
|
|
975
|
+
}
|
|
976
|
+
clinicMap.get(clinicId)!.all.push(appointment);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
noShow.forEach(appointment => {
|
|
980
|
+
const clinicId = appointment.clinicBranchId;
|
|
981
|
+
if (clinicMap.has(clinicId)) {
|
|
982
|
+
clinicMap.get(clinicId)!.noShow.push(appointment);
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
return Array.from(clinicMap.entries()).map(([clinicId, data]) => ({
|
|
987
|
+
entityId: clinicId,
|
|
988
|
+
entityName: data.name,
|
|
989
|
+
entityType: 'clinic' as EntityType,
|
|
990
|
+
totalAppointments: data.all.length,
|
|
991
|
+
noShowAppointments: data.noShow.length,
|
|
992
|
+
noShowRate: calculatePercentage(data.noShow.length, data.all.length),
|
|
993
|
+
}));
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Group no-shows by practitioner
|
|
998
|
+
*/
|
|
999
|
+
private groupNoShowsByPractitioner(
|
|
1000
|
+
noShow: Appointment[],
|
|
1001
|
+
allAppointments: Appointment[],
|
|
1002
|
+
): NoShowMetrics[] {
|
|
1003
|
+
const practitionerMap = new Map<
|
|
1004
|
+
string,
|
|
1005
|
+
{ name: string; noShow: Appointment[]; all: Appointment[] }
|
|
1006
|
+
>();
|
|
1007
|
+
|
|
1008
|
+
allAppointments.forEach(appointment => {
|
|
1009
|
+
const practitionerId = appointment.practitionerId;
|
|
1010
|
+
const practitionerName = appointment.practitionerInfo?.name || 'Unknown';
|
|
1011
|
+
|
|
1012
|
+
if (!practitionerMap.has(practitionerId)) {
|
|
1013
|
+
practitionerMap.set(practitionerId, { name: practitionerName, noShow: [], all: [] });
|
|
1014
|
+
}
|
|
1015
|
+
practitionerMap.get(practitionerId)!.all.push(appointment);
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
noShow.forEach(appointment => {
|
|
1019
|
+
const practitionerId = appointment.practitionerId;
|
|
1020
|
+
if (practitionerMap.has(practitionerId)) {
|
|
1021
|
+
practitionerMap.get(practitionerId)!.noShow.push(appointment);
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
return Array.from(practitionerMap.entries()).map(([practitionerId, data]) => ({
|
|
1026
|
+
entityId: practitionerId,
|
|
1027
|
+
entityName: data.name,
|
|
1028
|
+
entityType: 'practitioner' as EntityType,
|
|
1029
|
+
totalAppointments: data.all.length,
|
|
1030
|
+
noShowAppointments: data.noShow.length,
|
|
1031
|
+
noShowRate: calculatePercentage(data.noShow.length, data.all.length),
|
|
1032
|
+
}));
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Group no-shows by patient
|
|
1037
|
+
*/
|
|
1038
|
+
private groupNoShowsByPatient(
|
|
1039
|
+
noShow: Appointment[],
|
|
1040
|
+
allAppointments: Appointment[],
|
|
1041
|
+
): NoShowMetrics[] {
|
|
1042
|
+
const patientMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[] }>();
|
|
1043
|
+
|
|
1044
|
+
allAppointments.forEach(appointment => {
|
|
1045
|
+
const patientId = appointment.patientId;
|
|
1046
|
+
const patientName = appointment.patientInfo?.fullName || 'Unknown';
|
|
1047
|
+
|
|
1048
|
+
if (!patientMap.has(patientId)) {
|
|
1049
|
+
patientMap.set(patientId, { name: patientName, noShow: [], all: [] });
|
|
1050
|
+
}
|
|
1051
|
+
patientMap.get(patientId)!.all.push(appointment);
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
noShow.forEach(appointment => {
|
|
1055
|
+
const patientId = appointment.patientId;
|
|
1056
|
+
if (patientMap.has(patientId)) {
|
|
1057
|
+
patientMap.get(patientId)!.noShow.push(appointment);
|
|
1058
|
+
}
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
return Array.from(patientMap.entries()).map(([patientId, data]) => ({
|
|
1062
|
+
entityId: patientId,
|
|
1063
|
+
entityName: data.name,
|
|
1064
|
+
entityType: 'patient' as EntityType,
|
|
1065
|
+
totalAppointments: data.all.length,
|
|
1066
|
+
noShowAppointments: data.noShow.length,
|
|
1067
|
+
noShowRate: calculatePercentage(data.noShow.length, data.all.length),
|
|
1068
|
+
}));
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Group no-shows by procedure
|
|
1073
|
+
*/
|
|
1074
|
+
private groupNoShowsByProcedure(
|
|
1075
|
+
noShow: Appointment[],
|
|
1076
|
+
allAppointments: Appointment[],
|
|
1077
|
+
): NoShowMetrics[] {
|
|
1078
|
+
const procedureMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[]; practitionerId?: string; practitionerName?: string }>();
|
|
1079
|
+
|
|
1080
|
+
allAppointments.forEach(appointment => {
|
|
1081
|
+
const procedureId = appointment.procedureId;
|
|
1082
|
+
const procedureName = appointment.procedureInfo?.name || 'Unknown';
|
|
1083
|
+
|
|
1084
|
+
if (!procedureMap.has(procedureId)) {
|
|
1085
|
+
procedureMap.set(procedureId, {
|
|
1086
|
+
name: procedureName,
|
|
1087
|
+
noShow: [],
|
|
1088
|
+
all: [],
|
|
1089
|
+
practitionerId: appointment.practitionerId,
|
|
1090
|
+
practitionerName: appointment.practitionerInfo?.name,
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
procedureMap.get(procedureId)!.all.push(appointment);
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
noShow.forEach(appointment => {
|
|
1097
|
+
const procedureId = appointment.procedureId;
|
|
1098
|
+
if (procedureMap.has(procedureId)) {
|
|
1099
|
+
procedureMap.get(procedureId)!.noShow.push(appointment);
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
return Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
|
|
1104
|
+
entityId: procedureId,
|
|
1105
|
+
entityName: data.name,
|
|
1106
|
+
entityType: 'procedure' as EntityType,
|
|
1107
|
+
totalAppointments: data.all.length,
|
|
1108
|
+
noShowAppointments: data.noShow.length,
|
|
1109
|
+
noShowRate: calculatePercentage(data.noShow.length, data.all.length),
|
|
1110
|
+
...(data.practitionerId && { practitionerId: data.practitionerId }),
|
|
1111
|
+
...(data.practitionerName && { practitionerName: data.practitionerName }),
|
|
1112
|
+
}));
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Group no-shows by technology
|
|
1117
|
+
* Aggregates all procedures using the same technology across all doctors
|
|
1118
|
+
*/
|
|
1119
|
+
private groupNoShowsByTechnology(
|
|
1120
|
+
noShow: Appointment[],
|
|
1121
|
+
allAppointments: Appointment[],
|
|
1122
|
+
): NoShowMetrics[] {
|
|
1123
|
+
const technologyMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[] }>();
|
|
1124
|
+
|
|
1125
|
+
allAppointments.forEach(appointment => {
|
|
1126
|
+
const technologyId =
|
|
1127
|
+
appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
|
|
1128
|
+
const technologyName =
|
|
1129
|
+
appointment.procedureExtendedInfo?.procedureTechnologyName ||
|
|
1130
|
+
appointment.procedureInfo?.technologyName ||
|
|
1131
|
+
'Unknown';
|
|
1132
|
+
|
|
1133
|
+
if (!technologyMap.has(technologyId)) {
|
|
1134
|
+
technologyMap.set(technologyId, { name: technologyName, noShow: [], all: [] });
|
|
1135
|
+
}
|
|
1136
|
+
technologyMap.get(technologyId)!.all.push(appointment);
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
noShow.forEach(appointment => {
|
|
1140
|
+
const technologyId =
|
|
1141
|
+
appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
|
|
1142
|
+
if (technologyMap.has(technologyId)) {
|
|
1143
|
+
technologyMap.get(technologyId)!.noShow.push(appointment);
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
return Array.from(technologyMap.entries()).map(([technologyId, data]) => ({
|
|
1148
|
+
entityId: technologyId,
|
|
1149
|
+
entityName: data.name,
|
|
1150
|
+
entityType: 'technology' as EntityType,
|
|
1151
|
+
totalAppointments: data.all.length,
|
|
1152
|
+
noShowAppointments: data.noShow.length,
|
|
1153
|
+
noShowRate: calculatePercentage(data.noShow.length, data.all.length),
|
|
1154
|
+
}));
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// ==========================================
|
|
1158
|
+
// Financial Analytics
|
|
1159
|
+
// ==========================================
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* Get revenue metrics grouped by clinic, practitioner, procedure, patient, or technology
|
|
1163
|
+
*
|
|
1164
|
+
* @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient' | 'technology'
|
|
1165
|
+
* @param dateRange - Optional date range filter
|
|
1166
|
+
* @param filters - Optional additional filters
|
|
1167
|
+
* @returns Grouped revenue metrics
|
|
1168
|
+
*/
|
|
1169
|
+
async getRevenueMetricsByEntity(
|
|
1170
|
+
groupBy: EntityType,
|
|
1171
|
+
dateRange?: AnalyticsDateRange,
|
|
1172
|
+
filters?: AnalyticsFilters,
|
|
1173
|
+
): Promise<GroupedRevenueMetrics[]> {
|
|
1174
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
1175
|
+
return calculateGroupedRevenueMetrics(appointments, groupBy);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Get revenue metrics
|
|
1180
|
+
* First checks for stored analytics, then calculates if not available or stale
|
|
1181
|
+
*
|
|
1182
|
+
* IMPORTANT: Financial calculations only consider COMPLETED appointments.
|
|
1183
|
+
* Confirmed, pending, canceled, and no-show appointments are NOT included in revenue calculations.
|
|
1184
|
+
* Only procedures that have been completed generate revenue.
|
|
1185
|
+
*
|
|
1186
|
+
* @param filters - Optional filters
|
|
1187
|
+
* @param dateRange - Optional date range filter
|
|
1188
|
+
* @param options - Options for reading stored analytics
|
|
1189
|
+
* @returns Revenue metrics
|
|
1190
|
+
*/
|
|
1191
|
+
async getRevenueMetrics(
|
|
1192
|
+
filters?: AnalyticsFilters,
|
|
1193
|
+
dateRange?: AnalyticsDateRange,
|
|
1194
|
+
options?: ReadStoredAnalyticsOptions,
|
|
1195
|
+
): Promise<RevenueMetrics> {
|
|
1196
|
+
// Try to read from stored analytics first
|
|
1197
|
+
if (filters?.clinicBranchId && dateRange && options?.useCache !== false) {
|
|
1198
|
+
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
1199
|
+
const stored = await readStoredRevenueMetrics(
|
|
1200
|
+
this.db,
|
|
1201
|
+
filters.clinicBranchId,
|
|
1202
|
+
{ ...options, period },
|
|
1203
|
+
);
|
|
1204
|
+
|
|
1205
|
+
if (stored) {
|
|
1206
|
+
// Return stored data (without metadata)
|
|
1207
|
+
const { metadata, ...metrics } = stored;
|
|
1208
|
+
return metrics;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// Fall back to calculation
|
|
1213
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
1214
|
+
const completed = getCompletedAppointments(appointments);
|
|
1215
|
+
|
|
1216
|
+
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
1217
|
+
|
|
1218
|
+
// Calculate revenue by status - ONLY for COMPLETED appointments
|
|
1219
|
+
// Financial calculations should only consider completed procedures
|
|
1220
|
+
const revenueByStatus: Partial<Record<AppointmentStatus, number>> = {};
|
|
1221
|
+
// Only calculate revenue for COMPLETED status (other statuses have no revenue)
|
|
1222
|
+
const { totalRevenue: completedRevenue } = calculateTotalRevenue(completed);
|
|
1223
|
+
revenueByStatus[AppointmentStatus.COMPLETED] = completedRevenue;
|
|
1224
|
+
// All other statuses have 0 revenue (confirmed, pending, canceled, etc. don't generate revenue)
|
|
1225
|
+
|
|
1226
|
+
// Calculate revenue by payment status
|
|
1227
|
+
const revenueByPaymentStatus: Partial<Record<PaymentStatus, number>> = {};
|
|
1228
|
+
Object.values(PaymentStatus).forEach(paymentStatus => {
|
|
1229
|
+
const paymentAppointments = completed.filter(a => a.paymentStatus === paymentStatus);
|
|
1230
|
+
const { totalRevenue: paymentRevenue } = calculateTotalRevenue(paymentAppointments);
|
|
1231
|
+
revenueByPaymentStatus[paymentStatus] = paymentRevenue;
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
const unpaid = completed.filter(a => a.paymentStatus === PaymentStatus.UNPAID);
|
|
1235
|
+
const refunded = completed.filter(a => a.paymentStatus === PaymentStatus.REFUNDED);
|
|
1236
|
+
|
|
1237
|
+
const { totalRevenue: unpaidRevenue } = calculateTotalRevenue(unpaid);
|
|
1238
|
+
const { totalRevenue: refundedRevenue } = calculateTotalRevenue(refunded);
|
|
1239
|
+
|
|
1240
|
+
// Calculate tax and subtotal from finalbilling if available
|
|
1241
|
+
let totalTax = 0;
|
|
1242
|
+
let totalSubtotal = 0;
|
|
1243
|
+
completed.forEach(appointment => {
|
|
1244
|
+
const costData = calculateAppointmentCost(appointment);
|
|
1245
|
+
if (costData.source === 'finalbilling') {
|
|
1246
|
+
totalTax += costData.tax || 0;
|
|
1247
|
+
totalSubtotal += costData.subtotal || 0;
|
|
1248
|
+
} else {
|
|
1249
|
+
totalSubtotal += costData.cost;
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
return {
|
|
1254
|
+
totalRevenue,
|
|
1255
|
+
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
1256
|
+
totalAppointments: appointments.length,
|
|
1257
|
+
completedAppointments: completed.length,
|
|
1258
|
+
currency,
|
|
1259
|
+
revenueByStatus,
|
|
1260
|
+
revenueByPaymentStatus,
|
|
1261
|
+
unpaidRevenue,
|
|
1262
|
+
refundedRevenue,
|
|
1263
|
+
totalTax,
|
|
1264
|
+
totalSubtotal,
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// ==========================================
|
|
1269
|
+
// Product Usage Analytics
|
|
1270
|
+
// ==========================================
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Get product usage metrics grouped by clinic, practitioner, procedure, or patient
|
|
1274
|
+
*
|
|
1275
|
+
* @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient'
|
|
1276
|
+
* @param dateRange - Optional date range filter
|
|
1277
|
+
* @param filters - Optional additional filters
|
|
1278
|
+
* @returns Grouped product usage metrics
|
|
1279
|
+
*/
|
|
1280
|
+
async getProductUsageMetricsByEntity(
|
|
1281
|
+
groupBy: EntityType,
|
|
1282
|
+
dateRange?: AnalyticsDateRange,
|
|
1283
|
+
filters?: AnalyticsFilters,
|
|
1284
|
+
): Promise<GroupedProductUsageMetrics[]> {
|
|
1285
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
1286
|
+
return calculateGroupedProductUsageMetrics(appointments, groupBy);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/**
|
|
1290
|
+
* Get product usage metrics
|
|
1291
|
+
*
|
|
1292
|
+
* IMPORTANT: Only COMPLETED appointments are included in product usage calculations.
|
|
1293
|
+
* Products are only considered "used" when the procedure has been completed.
|
|
1294
|
+
* Confirmed, pending, canceled, and no-show appointments are excluded from product metrics.
|
|
1295
|
+
*
|
|
1296
|
+
* @param productId - Optional product ID (if not provided, returns all products)
|
|
1297
|
+
* @param dateRange - Optional date range filter
|
|
1298
|
+
* @param filters - Optional filters (e.g., clinicBranchId)
|
|
1299
|
+
* @returns Product usage metrics
|
|
1300
|
+
*/
|
|
1301
|
+
async getProductUsageMetrics(
|
|
1302
|
+
productId?: string,
|
|
1303
|
+
dateRange?: AnalyticsDateRange,
|
|
1304
|
+
filters?: AnalyticsFilters,
|
|
1305
|
+
): Promise<ProductUsageMetrics | ProductUsageMetrics[]> {
|
|
1306
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
1307
|
+
const completed = getCompletedAppointments(appointments);
|
|
1308
|
+
|
|
1309
|
+
const productMap = new Map<
|
|
1310
|
+
string,
|
|
1311
|
+
{
|
|
1312
|
+
name: string;
|
|
1313
|
+
brandId: string;
|
|
1314
|
+
brandName: string;
|
|
1315
|
+
quantity: number;
|
|
1316
|
+
revenue: number;
|
|
1317
|
+
usageCount: number;
|
|
1318
|
+
appointmentIds: Set<string>; // Track which appointments used this product
|
|
1319
|
+
procedureMap: Map<string, { name: string; count: number; quantity: number }>;
|
|
1320
|
+
}
|
|
1321
|
+
>();
|
|
1322
|
+
|
|
1323
|
+
completed.forEach(appointment => {
|
|
1324
|
+
const products = extractProductUsage(appointment);
|
|
1325
|
+
// Track which products were used in this appointment to count appointments, not product entries
|
|
1326
|
+
const productsInThisAppointment = new Set<string>();
|
|
1327
|
+
|
|
1328
|
+
products.forEach(product => {
|
|
1329
|
+
if (productId && product.productId !== productId) {
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
if (!productMap.has(product.productId)) {
|
|
1334
|
+
productMap.set(product.productId, {
|
|
1335
|
+
name: product.productName,
|
|
1336
|
+
brandId: product.brandId,
|
|
1337
|
+
brandName: product.brandName,
|
|
1338
|
+
quantity: 0,
|
|
1339
|
+
revenue: 0,
|
|
1340
|
+
usageCount: 0,
|
|
1341
|
+
appointmentIds: new Set(),
|
|
1342
|
+
procedureMap: new Map(),
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const productData = productMap.get(product.productId)!;
|
|
1347
|
+
productData.quantity += product.quantity;
|
|
1348
|
+
productData.revenue += product.subtotal;
|
|
1349
|
+
|
|
1350
|
+
// Track that this product was used in this appointment
|
|
1351
|
+
productsInThisAppointment.add(product.productId);
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
// After processing all products from this appointment, increment usageCount once per product
|
|
1355
|
+
productsInThisAppointment.forEach(productId => {
|
|
1356
|
+
const productData = productMap.get(productId)!;
|
|
1357
|
+
if (!productData.appointmentIds.has(appointment.id)) {
|
|
1358
|
+
productData.appointmentIds.add(appointment.id);
|
|
1359
|
+
productData.usageCount++;
|
|
1360
|
+
|
|
1361
|
+
// Track usage by procedure (only once per appointment)
|
|
1362
|
+
const procId = appointment.procedureId;
|
|
1363
|
+
const procName = appointment.procedureInfo?.name || 'Unknown';
|
|
1364
|
+
if (productData.procedureMap.has(procId)) {
|
|
1365
|
+
const procData = productData.procedureMap.get(procId)!;
|
|
1366
|
+
procData.count++;
|
|
1367
|
+
// Sum all quantities for this product in this appointment
|
|
1368
|
+
const appointmentProducts = products.filter(p => p.productId === productId);
|
|
1369
|
+
procData.quantity += appointmentProducts.reduce((sum, p) => sum + p.quantity, 0);
|
|
1370
|
+
} else {
|
|
1371
|
+
const appointmentProducts = products.filter(p => p.productId === productId);
|
|
1372
|
+
const totalQuantity = appointmentProducts.reduce((sum, p) => sum + p.quantity, 0);
|
|
1373
|
+
productData.procedureMap.set(procId, {
|
|
1374
|
+
name: procName,
|
|
1375
|
+
count: 1,
|
|
1376
|
+
quantity: totalQuantity,
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
});
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
const results = Array.from(productMap.entries()).map(([productId, data]) => ({
|
|
1384
|
+
productId,
|
|
1385
|
+
productName: data.name,
|
|
1386
|
+
brandId: data.brandId,
|
|
1387
|
+
brandName: data.brandName,
|
|
1388
|
+
totalQuantity: data.quantity,
|
|
1389
|
+
totalRevenue: data.revenue,
|
|
1390
|
+
averagePrice: data.usageCount > 0 ? data.revenue / data.quantity : 0,
|
|
1391
|
+
currency: 'CHF', // Could be extracted from products
|
|
1392
|
+
usageCount: data.usageCount,
|
|
1393
|
+
averageQuantityPerAppointment:
|
|
1394
|
+
data.usageCount > 0 ? data.quantity / data.usageCount : 0,
|
|
1395
|
+
usageByProcedure: Array.from(data.procedureMap.entries()).map(([procId, procData]) => ({
|
|
1396
|
+
procedureId: procId,
|
|
1397
|
+
procedureName: procData.name,
|
|
1398
|
+
count: procData.count,
|
|
1399
|
+
totalQuantity: procData.quantity,
|
|
1400
|
+
})),
|
|
1401
|
+
}));
|
|
1402
|
+
|
|
1403
|
+
return productId ? results[0] : results;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// ==========================================
|
|
1407
|
+
// Patient Analytics
|
|
1408
|
+
// ==========================================
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* Get patient behavior metrics grouped by clinic, practitioner, procedure, or technology
|
|
1412
|
+
* Shows patient no-show and cancellation patterns per entity
|
|
1413
|
+
*
|
|
1414
|
+
* @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'technology'
|
|
1415
|
+
* @param dateRange - Optional date range filter
|
|
1416
|
+
* @param filters - Optional additional filters
|
|
1417
|
+
* @returns Grouped patient behavior metrics
|
|
1418
|
+
*/
|
|
1419
|
+
async getPatientBehaviorMetricsByEntity(
|
|
1420
|
+
groupBy: 'clinic' | 'practitioner' | 'procedure' | 'technology',
|
|
1421
|
+
dateRange?: AnalyticsDateRange,
|
|
1422
|
+
filters?: AnalyticsFilters,
|
|
1423
|
+
): Promise<GroupedPatientBehaviorMetrics[]> {
|
|
1424
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
1425
|
+
return calculateGroupedPatientBehaviorMetrics(appointments, groupBy);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* Get patient analytics
|
|
1430
|
+
*
|
|
1431
|
+
* @param patientId - Optional patient ID (if not provided, returns aggregate)
|
|
1432
|
+
* @param dateRange - Optional date range filter
|
|
1433
|
+
* @returns Patient analytics
|
|
1434
|
+
*/
|
|
1435
|
+
async getPatientAnalytics(
|
|
1436
|
+
patientId?: string,
|
|
1437
|
+
dateRange?: AnalyticsDateRange,
|
|
1438
|
+
): Promise<PatientAnalytics | PatientAnalytics[]> {
|
|
1439
|
+
const appointments = await this.fetchAppointments(patientId ? { patientId } : undefined, dateRange);
|
|
1440
|
+
|
|
1441
|
+
if (patientId) {
|
|
1442
|
+
return this.calculatePatientAnalytics(appointments, patientId);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// Group by patient
|
|
1446
|
+
const patientMap = new Map<string, Appointment[]>();
|
|
1447
|
+
appointments.forEach(appointment => {
|
|
1448
|
+
const patId = appointment.patientId;
|
|
1449
|
+
if (!patientMap.has(patId)) {
|
|
1450
|
+
patientMap.set(patId, []);
|
|
1451
|
+
}
|
|
1452
|
+
patientMap.get(patId)!.push(appointment);
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1455
|
+
return Array.from(patientMap.entries()).map(([patId, patAppointments]) =>
|
|
1456
|
+
this.calculatePatientAnalytics(patAppointments, patId),
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
* Calculate analytics for a specific patient
|
|
1462
|
+
*/
|
|
1463
|
+
private calculatePatientAnalytics(appointments: Appointment[], patientId: string): PatientAnalytics {
|
|
1464
|
+
const completed = getCompletedAppointments(appointments);
|
|
1465
|
+
const canceled = getCanceledAppointments(appointments);
|
|
1466
|
+
const noShow = getNoShowAppointments(appointments);
|
|
1467
|
+
|
|
1468
|
+
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
1469
|
+
|
|
1470
|
+
// Get appointment dates
|
|
1471
|
+
const appointmentDates = appointments
|
|
1472
|
+
.map(a => a.appointmentStartTime.toDate())
|
|
1473
|
+
.sort((a, b) => a.getTime() - b.getTime());
|
|
1474
|
+
|
|
1475
|
+
const firstAppointmentDate = appointmentDates.length > 0 ? appointmentDates[0] : null;
|
|
1476
|
+
const lastAppointmentDate =
|
|
1477
|
+
appointmentDates.length > 0 ? appointmentDates[appointmentDates.length - 1] : null;
|
|
1478
|
+
|
|
1479
|
+
// Calculate average days between appointments
|
|
1480
|
+
let averageDaysBetween = null;
|
|
1481
|
+
if (appointmentDates.length > 1) {
|
|
1482
|
+
const intervals: number[] = [];
|
|
1483
|
+
for (let i = 1; i < appointmentDates.length; i++) {
|
|
1484
|
+
const diffMs = appointmentDates[i].getTime() - appointmentDates[i - 1].getTime();
|
|
1485
|
+
intervals.push(diffMs / (1000 * 60 * 60 * 24));
|
|
1486
|
+
}
|
|
1487
|
+
averageDaysBetween = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// Get unique practitioners and clinics
|
|
1491
|
+
const uniquePractitioners = new Set(appointments.map(a => a.practitionerId));
|
|
1492
|
+
const uniqueClinics = new Set(appointments.map(a => a.clinicBranchId));
|
|
1493
|
+
|
|
1494
|
+
// Get favorite procedures
|
|
1495
|
+
const procedureMap = new Map<string, { name: string; count: number }>();
|
|
1496
|
+
completed.forEach(appointment => {
|
|
1497
|
+
const procId = appointment.procedureId;
|
|
1498
|
+
const procName = appointment.procedureInfo?.name || 'Unknown';
|
|
1499
|
+
procedureMap.set(procId, {
|
|
1500
|
+
name: procName,
|
|
1501
|
+
count: (procedureMap.get(procId)?.count || 0) + 1,
|
|
1502
|
+
});
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
const favoriteProcedures = Array.from(procedureMap.entries())
|
|
1506
|
+
.map(([procedureId, data]) => ({
|
|
1507
|
+
procedureId,
|
|
1508
|
+
procedureName: data.name,
|
|
1509
|
+
count: data.count,
|
|
1510
|
+
}))
|
|
1511
|
+
.sort((a, b) => b.count - a.count)
|
|
1512
|
+
.slice(0, 5);
|
|
1513
|
+
|
|
1514
|
+
const patientName = appointments.length > 0 ? appointments[0].patientInfo?.fullName || 'Unknown' : 'Unknown';
|
|
1515
|
+
|
|
1516
|
+
return {
|
|
1517
|
+
patientId,
|
|
1518
|
+
patientName,
|
|
1519
|
+
totalAppointments: appointments.length,
|
|
1520
|
+
completedAppointments: completed.length,
|
|
1521
|
+
canceledAppointments: canceled.length,
|
|
1522
|
+
noShowAppointments: noShow.length,
|
|
1523
|
+
cancellationRate: calculatePercentage(canceled.length, appointments.length),
|
|
1524
|
+
noShowRate: calculatePercentage(noShow.length, appointments.length),
|
|
1525
|
+
totalRevenue,
|
|
1526
|
+
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
1527
|
+
currency,
|
|
1528
|
+
lifetimeValue: totalRevenue,
|
|
1529
|
+
firstAppointmentDate,
|
|
1530
|
+
lastAppointmentDate,
|
|
1531
|
+
averageDaysBetweenAppointments: averageDaysBetween ? Math.round(averageDaysBetween) : null,
|
|
1532
|
+
uniquePractitioners: uniquePractitioners.size,
|
|
1533
|
+
uniqueClinics: uniqueClinics.size,
|
|
1534
|
+
favoriteProcedures,
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// ==========================================
|
|
1539
|
+
// Dashboard Analytics
|
|
1540
|
+
// ==========================================
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* Determines analytics period from date range
|
|
1544
|
+
*/
|
|
1545
|
+
private determinePeriodFromDateRange(dateRange: AnalyticsDateRange): AnalyticsPeriod {
|
|
1546
|
+
const diffMs = dateRange.end.getTime() - dateRange.start.getTime();
|
|
1547
|
+
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
|
1548
|
+
|
|
1549
|
+
if (diffDays <= 1) return 'daily';
|
|
1550
|
+
if (diffDays <= 7) return 'weekly';
|
|
1551
|
+
if (diffDays <= 31) return 'monthly';
|
|
1552
|
+
if (diffDays <= 365) return 'yearly';
|
|
1553
|
+
return 'all_time';
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/**
|
|
1557
|
+
* Get comprehensive dashboard data
|
|
1558
|
+
* First checks for stored analytics, then calculates if not available or stale
|
|
1559
|
+
*
|
|
1560
|
+
* @param filters - Optional filters
|
|
1561
|
+
* @param dateRange - Optional date range filter
|
|
1562
|
+
* @param options - Options for reading stored analytics
|
|
1563
|
+
* @returns Complete dashboard analytics
|
|
1564
|
+
*/
|
|
1565
|
+
async getDashboardData(
|
|
1566
|
+
filters?: AnalyticsFilters,
|
|
1567
|
+
dateRange?: AnalyticsDateRange,
|
|
1568
|
+
options?: ReadStoredAnalyticsOptions,
|
|
1569
|
+
): Promise<DashboardAnalytics> {
|
|
1570
|
+
// Try to read from stored analytics first
|
|
1571
|
+
if (filters?.clinicBranchId && dateRange && options?.useCache !== false) {
|
|
1572
|
+
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
1573
|
+
const stored = await readStoredDashboardAnalytics(
|
|
1574
|
+
this.db,
|
|
1575
|
+
filters.clinicBranchId,
|
|
1576
|
+
{ ...options, period },
|
|
1577
|
+
);
|
|
1578
|
+
|
|
1579
|
+
if (stored) {
|
|
1580
|
+
const { metadata, ...analytics } = stored;
|
|
1581
|
+
return analytics;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// Fall back to calculation
|
|
1586
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
1587
|
+
|
|
1588
|
+
const completed = getCompletedAppointments(appointments);
|
|
1589
|
+
const canceled = getCanceledAppointments(appointments);
|
|
1590
|
+
const noShow = getNoShowAppointments(appointments);
|
|
1591
|
+
const pending = appointments.filter(a => a.status === AppointmentStatus.PENDING);
|
|
1592
|
+
const confirmed = appointments.filter(a => a.status === AppointmentStatus.CONFIRMED);
|
|
1593
|
+
|
|
1594
|
+
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
1595
|
+
|
|
1596
|
+
// Get unique counts
|
|
1597
|
+
const uniquePatients = new Set(appointments.map(a => a.patientId));
|
|
1598
|
+
const uniquePractitioners = new Set(appointments.map(a => a.practitionerId));
|
|
1599
|
+
const uniqueProcedures = new Set(appointments.map(a => a.procedureId));
|
|
1600
|
+
|
|
1601
|
+
// Get top practitioners (limit to 5)
|
|
1602
|
+
const practitionerMetrics = await Promise.all(
|
|
1603
|
+
Array.from(uniquePractitioners)
|
|
1604
|
+
.slice(0, 5)
|
|
1605
|
+
.map(practitionerId => this.getPractitionerAnalytics(practitionerId, dateRange)),
|
|
1606
|
+
);
|
|
1607
|
+
|
|
1608
|
+
// Get top procedures (limit to 5)
|
|
1609
|
+
const procedureMetricsResults = await Promise.all(
|
|
1610
|
+
Array.from(uniqueProcedures)
|
|
1611
|
+
.slice(0, 5)
|
|
1612
|
+
.map(procedureId => this.getProcedureAnalytics(procedureId, dateRange)),
|
|
1613
|
+
);
|
|
1614
|
+
// Filter out arrays and ensure we have ProcedureAnalytics objects
|
|
1615
|
+
const procedureMetrics = procedureMetricsResults.filter(
|
|
1616
|
+
(result): result is ProcedureAnalytics => !Array.isArray(result),
|
|
1617
|
+
);
|
|
1618
|
+
|
|
1619
|
+
// Get cancellation and no-show metrics (aggregated)
|
|
1620
|
+
const cancellationMetrics = await this.getCancellationMetrics('clinic', dateRange);
|
|
1621
|
+
const noShowMetrics = await this.getNoShowMetrics('clinic', dateRange);
|
|
1622
|
+
|
|
1623
|
+
// Get time efficiency
|
|
1624
|
+
const timeEfficiency = await this.getTimeEfficiencyMetrics(filters, dateRange);
|
|
1625
|
+
|
|
1626
|
+
// Get top products
|
|
1627
|
+
const productMetrics = await this.getProductUsageMetrics(undefined, dateRange);
|
|
1628
|
+
const topProducts = Array.isArray(productMetrics)
|
|
1629
|
+
? productMetrics.sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, 5)
|
|
1630
|
+
: [];
|
|
1631
|
+
|
|
1632
|
+
// Get recent activity (last 10 appointments)
|
|
1633
|
+
const recentActivity = appointments
|
|
1634
|
+
.sort((a, b) => b.appointmentStartTime.toMillis() - a.appointmentStartTime.toMillis())
|
|
1635
|
+
.slice(0, 10)
|
|
1636
|
+
.map(appointment => {
|
|
1637
|
+
let type: 'appointment' | 'cancellation' | 'completion' | 'no_show' = 'appointment';
|
|
1638
|
+
let description = '';
|
|
1639
|
+
|
|
1640
|
+
if (appointment.status === AppointmentStatus.COMPLETED) {
|
|
1641
|
+
type = 'completion';
|
|
1642
|
+
description = `Appointment completed: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
|
|
1643
|
+
} else if (
|
|
1644
|
+
appointment.status === AppointmentStatus.CANCELED_PATIENT ||
|
|
1645
|
+
appointment.status === AppointmentStatus.CANCELED_CLINIC ||
|
|
1646
|
+
appointment.status === AppointmentStatus.CANCELED_PATIENT_RESCHEDULED
|
|
1647
|
+
) {
|
|
1648
|
+
type = 'cancellation';
|
|
1649
|
+
description = `Appointment canceled: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
|
|
1650
|
+
} else if (appointment.status === AppointmentStatus.NO_SHOW) {
|
|
1651
|
+
type = 'no_show';
|
|
1652
|
+
description = `No-show: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
|
|
1653
|
+
} else {
|
|
1654
|
+
description = `Appointment ${appointment.status}: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
return {
|
|
1658
|
+
type,
|
|
1659
|
+
date: appointment.appointmentStartTime.toDate(),
|
|
1660
|
+
description,
|
|
1661
|
+
entityId: appointment.practitionerId,
|
|
1662
|
+
entityName: appointment.practitionerInfo?.name || 'Unknown',
|
|
1663
|
+
};
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
return {
|
|
1667
|
+
overview: {
|
|
1668
|
+
totalAppointments: appointments.length,
|
|
1669
|
+
completedAppointments: completed.length,
|
|
1670
|
+
canceledAppointments: canceled.length,
|
|
1671
|
+
noShowAppointments: noShow.length,
|
|
1672
|
+
pendingAppointments: pending.length,
|
|
1673
|
+
confirmedAppointments: confirmed.length,
|
|
1674
|
+
totalRevenue,
|
|
1675
|
+
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
1676
|
+
currency,
|
|
1677
|
+
uniquePatients: uniquePatients.size,
|
|
1678
|
+
uniquePractitioners: uniquePractitioners.size,
|
|
1679
|
+
uniqueProcedures: uniqueProcedures.size,
|
|
1680
|
+
cancellationRate: calculatePercentage(canceled.length, appointments.length),
|
|
1681
|
+
noShowRate: calculatePercentage(noShow.length, appointments.length),
|
|
1682
|
+
},
|
|
1683
|
+
practitionerMetrics: Array.isArray(practitionerMetrics) ? practitionerMetrics : [],
|
|
1684
|
+
procedureMetrics: Array.isArray(procedureMetrics) ? procedureMetrics : [],
|
|
1685
|
+
cancellationMetrics: Array.isArray(cancellationMetrics) ? cancellationMetrics[0] : cancellationMetrics,
|
|
1686
|
+
noShowMetrics: Array.isArray(noShowMetrics) ? noShowMetrics[0] : noShowMetrics,
|
|
1687
|
+
revenueTrends: [], // TODO: Implement revenue trends
|
|
1688
|
+
timeEfficiency,
|
|
1689
|
+
topProducts,
|
|
1690
|
+
recentActivity,
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/**
|
|
1695
|
+
* Calculate revenue trends over time
|
|
1696
|
+
* Groups appointments by week/month/quarter/year and calculates revenue metrics
|
|
1697
|
+
*
|
|
1698
|
+
* @param dateRange - Date range for trend analysis (must align with period boundaries)
|
|
1699
|
+
* @param period - Period type (week, month, quarter, year)
|
|
1700
|
+
* @param filters - Optional filters for clinic, practitioner, procedure, patient
|
|
1701
|
+
* @param groupBy - Optional entity type to group trends by (clinic, practitioner, procedure, technology, patient)
|
|
1702
|
+
* @returns Array of revenue trends with percentage changes
|
|
1703
|
+
*/
|
|
1704
|
+
async getRevenueTrends(
|
|
1705
|
+
dateRange: AnalyticsDateRange,
|
|
1706
|
+
period: TrendPeriod,
|
|
1707
|
+
filters?: AnalyticsFilters,
|
|
1708
|
+
groupBy?: EntityType,
|
|
1709
|
+
): Promise<RevenueTrend[]> {
|
|
1710
|
+
const appointments = await this.fetchAppointments(filters);
|
|
1711
|
+
const filtered = filterByDateRange(appointments, dateRange);
|
|
1712
|
+
|
|
1713
|
+
if (filtered.length === 0) {
|
|
1714
|
+
return [];
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// If grouping by entity, calculate trends per entity
|
|
1718
|
+
if (groupBy) {
|
|
1719
|
+
return this.getGroupedRevenueTrends(filtered, dateRange, period, groupBy);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// Calculate overall trends
|
|
1723
|
+
const periodMap = groupAppointmentsByPeriod(filtered, period as TrendPeriodType);
|
|
1724
|
+
const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
|
|
1725
|
+
const trends: RevenueTrend[] = [];
|
|
1726
|
+
|
|
1727
|
+
let previousRevenue = 0;
|
|
1728
|
+
let previousAppointmentCount = 0;
|
|
1729
|
+
|
|
1730
|
+
periods.forEach(periodInfo => {
|
|
1731
|
+
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
1732
|
+
const completed = getCompletedAppointments(periodAppointments);
|
|
1733
|
+
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
1734
|
+
|
|
1735
|
+
const appointmentCount = completed.length;
|
|
1736
|
+
const averageRevenue = appointmentCount > 0 ? totalRevenue / appointmentCount : 0;
|
|
1737
|
+
|
|
1738
|
+
const trend: RevenueTrend = {
|
|
1739
|
+
period: periodInfo.period,
|
|
1740
|
+
startDate: periodInfo.startDate,
|
|
1741
|
+
endDate: periodInfo.endDate,
|
|
1742
|
+
revenue: totalRevenue,
|
|
1743
|
+
appointmentCount,
|
|
1744
|
+
averageRevenue,
|
|
1745
|
+
currency,
|
|
1746
|
+
};
|
|
1747
|
+
|
|
1748
|
+
// Calculate percentage change from previous period
|
|
1749
|
+
if (previousRevenue > 0 || previousAppointmentCount > 0) {
|
|
1750
|
+
const revenueChange = getTrendChange(totalRevenue, previousRevenue);
|
|
1751
|
+
trend.previousPeriod = {
|
|
1752
|
+
revenue: previousRevenue,
|
|
1753
|
+
appointmentCount: previousAppointmentCount,
|
|
1754
|
+
percentageChange: revenueChange.percentageChange,
|
|
1755
|
+
direction: revenueChange.direction,
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
trends.push(trend);
|
|
1760
|
+
previousRevenue = totalRevenue;
|
|
1761
|
+
previousAppointmentCount = appointmentCount;
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
return trends;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
/**
|
|
1768
|
+
* Calculate revenue trends grouped by entity
|
|
1769
|
+
*/
|
|
1770
|
+
private async getGroupedRevenueTrends(
|
|
1771
|
+
appointments: Appointment[],
|
|
1772
|
+
dateRange: AnalyticsDateRange,
|
|
1773
|
+
period: TrendPeriod,
|
|
1774
|
+
groupBy: EntityType,
|
|
1775
|
+
): Promise<RevenueTrend[]> {
|
|
1776
|
+
const periodMap = groupAppointmentsByPeriod(appointments, period as TrendPeriodType);
|
|
1777
|
+
const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
|
|
1778
|
+
const trends: RevenueTrend[] = [];
|
|
1779
|
+
|
|
1780
|
+
// Group appointments by entity for each period
|
|
1781
|
+
periods.forEach(periodInfo => {
|
|
1782
|
+
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
1783
|
+
if (periodAppointments.length === 0) return;
|
|
1784
|
+
|
|
1785
|
+
const groupedMetrics = calculateGroupedRevenueMetrics(periodAppointments, groupBy);
|
|
1786
|
+
|
|
1787
|
+
// Sum up all entities for this period
|
|
1788
|
+
const totalRevenue = groupedMetrics.reduce((sum, m) => sum + m.totalRevenue, 0);
|
|
1789
|
+
const totalAppointments = groupedMetrics.reduce((sum, m) => sum + m.totalAppointments, 0);
|
|
1790
|
+
const currency = groupedMetrics[0]?.currency || 'CHF';
|
|
1791
|
+
const averageRevenue = totalAppointments > 0 ? totalRevenue / totalAppointments : 0;
|
|
1792
|
+
|
|
1793
|
+
trends.push({
|
|
1794
|
+
period: periodInfo.period,
|
|
1795
|
+
startDate: periodInfo.startDate,
|
|
1796
|
+
endDate: periodInfo.endDate,
|
|
1797
|
+
revenue: totalRevenue,
|
|
1798
|
+
appointmentCount: totalAppointments,
|
|
1799
|
+
averageRevenue,
|
|
1800
|
+
currency,
|
|
1801
|
+
});
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
// Calculate percentage changes
|
|
1805
|
+
for (let i = 1; i < trends.length; i++) {
|
|
1806
|
+
const current = trends[i];
|
|
1807
|
+
const previous = trends[i - 1];
|
|
1808
|
+
const revenueChange = getTrendChange(current.revenue, previous.revenue);
|
|
1809
|
+
|
|
1810
|
+
current.previousPeriod = {
|
|
1811
|
+
revenue: previous.revenue,
|
|
1812
|
+
appointmentCount: previous.appointmentCount,
|
|
1813
|
+
percentageChange: revenueChange.percentageChange,
|
|
1814
|
+
direction: revenueChange.direction,
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
return trends;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
/**
|
|
1822
|
+
* Calculate duration/efficiency trends over time
|
|
1823
|
+
*
|
|
1824
|
+
* @param dateRange - Date range for trend analysis
|
|
1825
|
+
* @param period - Period type (week, month, quarter, year)
|
|
1826
|
+
* @param filters - Optional filters
|
|
1827
|
+
* @param groupBy - Optional entity type to group trends by
|
|
1828
|
+
* @returns Array of duration trends with percentage changes
|
|
1829
|
+
*/
|
|
1830
|
+
async getDurationTrends(
|
|
1831
|
+
dateRange: AnalyticsDateRange,
|
|
1832
|
+
period: TrendPeriod,
|
|
1833
|
+
filters?: AnalyticsFilters,
|
|
1834
|
+
groupBy?: EntityType,
|
|
1835
|
+
): Promise<DurationTrend[]> {
|
|
1836
|
+
const appointments = await this.fetchAppointments(filters);
|
|
1837
|
+
const filtered = filterByDateRange(appointments, dateRange);
|
|
1838
|
+
|
|
1839
|
+
if (filtered.length === 0) {
|
|
1840
|
+
return [];
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
const periodMap = groupAppointmentsByPeriod(filtered, period as TrendPeriodType);
|
|
1844
|
+
const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
|
|
1845
|
+
const trends: DurationTrend[] = [];
|
|
1846
|
+
|
|
1847
|
+
let previousEfficiency = 0;
|
|
1848
|
+
let previousBookedDuration = 0;
|
|
1849
|
+
let previousActualDuration = 0;
|
|
1850
|
+
|
|
1851
|
+
periods.forEach(periodInfo => {
|
|
1852
|
+
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
1853
|
+
const completed = getCompletedAppointments(periodAppointments);
|
|
1854
|
+
|
|
1855
|
+
if (groupBy) {
|
|
1856
|
+
// Group by entity and calculate average
|
|
1857
|
+
const groupedMetrics = calculateGroupedTimeEfficiencyMetrics(completed, groupBy);
|
|
1858
|
+
if (groupedMetrics.length === 0) return;
|
|
1859
|
+
|
|
1860
|
+
const totalAppointments = groupedMetrics.reduce((sum, m) => sum + m.totalAppointments, 0);
|
|
1861
|
+
const weightedBooked = groupedMetrics.reduce(
|
|
1862
|
+
(sum, m) => sum + m.averageBookedDuration * m.totalAppointments,
|
|
1863
|
+
0,
|
|
1864
|
+
);
|
|
1865
|
+
const weightedActual = groupedMetrics.reduce(
|
|
1866
|
+
(sum, m) => sum + m.averageActualDuration * m.totalAppointments,
|
|
1867
|
+
0,
|
|
1868
|
+
);
|
|
1869
|
+
const weightedEfficiency = groupedMetrics.reduce(
|
|
1870
|
+
(sum, m) => sum + m.averageEfficiency * m.totalAppointments,
|
|
1871
|
+
0,
|
|
1872
|
+
);
|
|
1873
|
+
|
|
1874
|
+
const averageBookedDuration = totalAppointments > 0 ? weightedBooked / totalAppointments : 0;
|
|
1875
|
+
const averageActualDuration = totalAppointments > 0 ? weightedActual / totalAppointments : 0;
|
|
1876
|
+
const averageEfficiency = totalAppointments > 0 ? weightedEfficiency / totalAppointments : 0;
|
|
1877
|
+
|
|
1878
|
+
const trend: DurationTrend = {
|
|
1879
|
+
period: periodInfo.period,
|
|
1880
|
+
startDate: periodInfo.startDate,
|
|
1881
|
+
endDate: periodInfo.endDate,
|
|
1882
|
+
averageBookedDuration,
|
|
1883
|
+
averageActualDuration,
|
|
1884
|
+
averageEfficiency,
|
|
1885
|
+
appointmentCount: totalAppointments,
|
|
1886
|
+
};
|
|
1887
|
+
|
|
1888
|
+
if (previousEfficiency > 0) {
|
|
1889
|
+
const efficiencyChange = getTrendChange(averageEfficiency, previousEfficiency);
|
|
1890
|
+
trend.previousPeriod = {
|
|
1891
|
+
averageBookedDuration: previousBookedDuration,
|
|
1892
|
+
averageActualDuration: previousActualDuration,
|
|
1893
|
+
averageEfficiency: previousEfficiency,
|
|
1894
|
+
efficiencyPercentageChange: efficiencyChange.percentageChange,
|
|
1895
|
+
direction: efficiencyChange.direction,
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
trends.push(trend);
|
|
1900
|
+
previousEfficiency = averageEfficiency;
|
|
1901
|
+
previousBookedDuration = averageBookedDuration;
|
|
1902
|
+
previousActualDuration = averageActualDuration;
|
|
1903
|
+
} else {
|
|
1904
|
+
// Overall trends
|
|
1905
|
+
const timeMetrics = calculateAverageTimeMetrics(completed);
|
|
1906
|
+
|
|
1907
|
+
const trend: DurationTrend = {
|
|
1908
|
+
period: periodInfo.period,
|
|
1909
|
+
startDate: periodInfo.startDate,
|
|
1910
|
+
endDate: periodInfo.endDate,
|
|
1911
|
+
averageBookedDuration: timeMetrics.averageBookedDuration,
|
|
1912
|
+
averageActualDuration: timeMetrics.averageActualDuration,
|
|
1913
|
+
averageEfficiency: timeMetrics.averageEfficiency,
|
|
1914
|
+
appointmentCount: timeMetrics.appointmentsWithActualTime,
|
|
1915
|
+
};
|
|
1916
|
+
|
|
1917
|
+
if (previousEfficiency > 0) {
|
|
1918
|
+
const efficiencyChange = getTrendChange(timeMetrics.averageEfficiency, previousEfficiency);
|
|
1919
|
+
trend.previousPeriod = {
|
|
1920
|
+
averageBookedDuration: previousBookedDuration,
|
|
1921
|
+
averageActualDuration: previousActualDuration,
|
|
1922
|
+
averageEfficiency: previousEfficiency,
|
|
1923
|
+
efficiencyPercentageChange: efficiencyChange.percentageChange,
|
|
1924
|
+
direction: efficiencyChange.direction,
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
trends.push(trend);
|
|
1929
|
+
previousEfficiency = timeMetrics.averageEfficiency;
|
|
1930
|
+
previousBookedDuration = timeMetrics.averageBookedDuration;
|
|
1931
|
+
previousActualDuration = timeMetrics.averageActualDuration;
|
|
1932
|
+
}
|
|
1933
|
+
});
|
|
1934
|
+
|
|
1935
|
+
return trends;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
/**
|
|
1939
|
+
* Calculate appointment count trends over time
|
|
1940
|
+
*
|
|
1941
|
+
* @param dateRange - Date range for trend analysis
|
|
1942
|
+
* @param period - Period type (week, month, quarter, year)
|
|
1943
|
+
* @param filters - Optional filters
|
|
1944
|
+
* @param groupBy - Optional entity type to group trends by
|
|
1945
|
+
* @returns Array of appointment trends with percentage changes
|
|
1946
|
+
*/
|
|
1947
|
+
async getAppointmentTrends(
|
|
1948
|
+
dateRange: AnalyticsDateRange,
|
|
1949
|
+
period: TrendPeriod,
|
|
1950
|
+
filters?: AnalyticsFilters,
|
|
1951
|
+
groupBy?: EntityType,
|
|
1952
|
+
): Promise<AppointmentTrend[]> {
|
|
1953
|
+
const appointments = await this.fetchAppointments(filters);
|
|
1954
|
+
const filtered = filterByDateRange(appointments, dateRange);
|
|
1955
|
+
|
|
1956
|
+
if (filtered.length === 0) {
|
|
1957
|
+
return [];
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
const periodMap = groupAppointmentsByPeriod(filtered, period as TrendPeriodType);
|
|
1961
|
+
const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
|
|
1962
|
+
const trends: AppointmentTrend[] = [];
|
|
1963
|
+
|
|
1964
|
+
let previousTotal = 0;
|
|
1965
|
+
let previousCompleted = 0;
|
|
1966
|
+
|
|
1967
|
+
periods.forEach(periodInfo => {
|
|
1968
|
+
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
1969
|
+
const completed = getCompletedAppointments(periodAppointments);
|
|
1970
|
+
const canceled = getCanceledAppointments(periodAppointments);
|
|
1971
|
+
const noShow = getNoShowAppointments(periodAppointments);
|
|
1972
|
+
const pending = periodAppointments.filter(a => a.status === AppointmentStatus.PENDING);
|
|
1973
|
+
const confirmed = periodAppointments.filter(a => a.status === AppointmentStatus.CONFIRMED);
|
|
1974
|
+
|
|
1975
|
+
const trend: AppointmentTrend = {
|
|
1976
|
+
period: periodInfo.period,
|
|
1977
|
+
startDate: periodInfo.startDate,
|
|
1978
|
+
endDate: periodInfo.endDate,
|
|
1979
|
+
totalAppointments: periodAppointments.length,
|
|
1980
|
+
completedAppointments: completed.length,
|
|
1981
|
+
canceledAppointments: canceled.length,
|
|
1982
|
+
noShowAppointments: noShow.length,
|
|
1983
|
+
pendingAppointments: pending.length,
|
|
1984
|
+
confirmedAppointments: confirmed.length,
|
|
1985
|
+
};
|
|
1986
|
+
|
|
1987
|
+
if (previousTotal > 0) {
|
|
1988
|
+
const totalChange = getTrendChange(periodAppointments.length, previousTotal);
|
|
1989
|
+
trend.previousPeriod = {
|
|
1990
|
+
totalAppointments: previousTotal,
|
|
1991
|
+
completedAppointments: previousCompleted,
|
|
1992
|
+
percentageChange: totalChange.percentageChange,
|
|
1993
|
+
direction: totalChange.direction,
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
trends.push(trend);
|
|
1998
|
+
previousTotal = periodAppointments.length;
|
|
1999
|
+
previousCompleted = completed.length;
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
return trends;
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
/**
|
|
2006
|
+
* Calculate cancellation and no-show rate trends over time
|
|
2007
|
+
*
|
|
2008
|
+
* @param dateRange - Date range for trend analysis
|
|
2009
|
+
* @param period - Period type (week, month, quarter, year)
|
|
2010
|
+
* @param filters - Optional filters
|
|
2011
|
+
* @param groupBy - Optional entity type to group trends by
|
|
2012
|
+
* @returns Array of cancellation rate trends with percentage changes
|
|
2013
|
+
*/
|
|
2014
|
+
async getCancellationRateTrends(
|
|
2015
|
+
dateRange: AnalyticsDateRange,
|
|
2016
|
+
period: TrendPeriod,
|
|
2017
|
+
filters?: AnalyticsFilters,
|
|
2018
|
+
groupBy?: EntityType,
|
|
2019
|
+
): Promise<CancellationRateTrend[]> {
|
|
2020
|
+
const appointments = await this.fetchAppointments(filters);
|
|
2021
|
+
const filtered = filterByDateRange(appointments, dateRange);
|
|
2022
|
+
|
|
2023
|
+
if (filtered.length === 0) {
|
|
2024
|
+
return [];
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
const periodMap = groupAppointmentsByPeriod(filtered, period as TrendPeriodType);
|
|
2028
|
+
const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
|
|
2029
|
+
const trends: CancellationRateTrend[] = [];
|
|
2030
|
+
|
|
2031
|
+
let previousCancellationRate = 0;
|
|
2032
|
+
let previousNoShowRate = 0;
|
|
2033
|
+
|
|
2034
|
+
periods.forEach(periodInfo => {
|
|
2035
|
+
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
2036
|
+
const canceled = getCanceledAppointments(periodAppointments);
|
|
2037
|
+
const noShow = getNoShowAppointments(periodAppointments);
|
|
2038
|
+
|
|
2039
|
+
const cancellationRate = calculatePercentage(canceled.length, periodAppointments.length);
|
|
2040
|
+
const noShowRate = calculatePercentage(noShow.length, periodAppointments.length);
|
|
2041
|
+
|
|
2042
|
+
const trend: CancellationRateTrend = {
|
|
2043
|
+
period: periodInfo.period,
|
|
2044
|
+
startDate: periodInfo.startDate,
|
|
2045
|
+
endDate: periodInfo.endDate,
|
|
2046
|
+
cancellationRate,
|
|
2047
|
+
noShowRate,
|
|
2048
|
+
totalAppointments: periodAppointments.length,
|
|
2049
|
+
canceledAppointments: canceled.length,
|
|
2050
|
+
noShowAppointments: noShow.length,
|
|
2051
|
+
};
|
|
2052
|
+
|
|
2053
|
+
if (previousCancellationRate > 0 || previousNoShowRate > 0) {
|
|
2054
|
+
const cancellationChange = getTrendChange(cancellationRate, previousCancellationRate);
|
|
2055
|
+
const noShowChange = getTrendChange(noShowRate, previousNoShowRate);
|
|
2056
|
+
|
|
2057
|
+
trend.previousPeriod = {
|
|
2058
|
+
cancellationRate: previousCancellationRate,
|
|
2059
|
+
noShowRate: previousNoShowRate,
|
|
2060
|
+
cancellationRateChange: cancellationChange.percentageChange,
|
|
2061
|
+
noShowRateChange: noShowChange.percentageChange,
|
|
2062
|
+
direction: cancellationChange.direction, // Use cancellation direction as primary
|
|
2063
|
+
};
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
trends.push(trend);
|
|
2067
|
+
previousCancellationRate = cancellationRate;
|
|
2068
|
+
previousNoShowRate = noShowRate;
|
|
2069
|
+
});
|
|
2070
|
+
|
|
2071
|
+
return trends;
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
// ==========================================
|
|
2075
|
+
// Review Analytics Methods
|
|
2076
|
+
// ==========================================
|
|
2077
|
+
|
|
2078
|
+
/**
|
|
2079
|
+
* Get review metrics for a specific entity (practitioner, procedure, etc.)
|
|
2080
|
+
*/
|
|
2081
|
+
async getReviewMetricsByEntity(
|
|
2082
|
+
entityType: 'practitioner' | 'procedure' | 'category' | 'subcategory' | 'technology',
|
|
2083
|
+
entityId: string,
|
|
2084
|
+
dateRange?: AnalyticsDateRange,
|
|
2085
|
+
filters?: AnalyticsFilters
|
|
2086
|
+
): Promise<ReviewAnalyticsMetrics | null> {
|
|
2087
|
+
return this.reviewAnalyticsService.getReviewMetricsByEntity(entityType, entityId, dateRange, filters);
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
/**
|
|
2091
|
+
* Get review metrics for multiple entities (grouped)
|
|
2092
|
+
*/
|
|
2093
|
+
async getReviewMetricsByEntities(
|
|
2094
|
+
entityType: 'practitioner' | 'procedure' | 'category' | 'subcategory' | 'technology',
|
|
2095
|
+
dateRange?: AnalyticsDateRange,
|
|
2096
|
+
filters?: AnalyticsFilters
|
|
2097
|
+
): Promise<ReviewAnalyticsMetrics[]> {
|
|
2098
|
+
return this.reviewAnalyticsService.getReviewMetricsByEntities(entityType, dateRange, filters);
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
/**
|
|
2102
|
+
* Get overall review averages for comparison
|
|
2103
|
+
*/
|
|
2104
|
+
async getOverallReviewAverages(
|
|
2105
|
+
dateRange?: AnalyticsDateRange,
|
|
2106
|
+
filters?: AnalyticsFilters
|
|
2107
|
+
): Promise<OverallReviewAverages> {
|
|
2108
|
+
return this.reviewAnalyticsService.getOverallReviewAverages(dateRange, filters);
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
/**
|
|
2112
|
+
* Get review details for a specific entity
|
|
2113
|
+
*/
|
|
2114
|
+
async getReviewDetails(
|
|
2115
|
+
entityType: 'practitioner' | 'procedure',
|
|
2116
|
+
entityId: string,
|
|
2117
|
+
dateRange?: AnalyticsDateRange,
|
|
2118
|
+
filters?: AnalyticsFilters
|
|
2119
|
+
): Promise<ReviewDetail[]> {
|
|
2120
|
+
return this.reviewAnalyticsService.getReviewDetails(entityType, entityId, dateRange, filters);
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
/**
|
|
2124
|
+
* Calculate review trends over time
|
|
2125
|
+
* Groups reviews by period and calculates rating and recommendation metrics
|
|
2126
|
+
*
|
|
2127
|
+
* @param dateRange - Date range for trend analysis
|
|
2128
|
+
* @param period - Period type (week, month, quarter, year)
|
|
2129
|
+
* @param filters - Optional filters for clinic, practitioner, procedure
|
|
2130
|
+
* @param entityType - Optional entity type to group trends by
|
|
2131
|
+
* @returns Array of review trends with percentage changes
|
|
2132
|
+
*/
|
|
2133
|
+
async getReviewTrends(
|
|
2134
|
+
dateRange: AnalyticsDateRange,
|
|
2135
|
+
period: TrendPeriod,
|
|
2136
|
+
filters?: AnalyticsFilters,
|
|
2137
|
+
entityType?: 'practitioner' | 'procedure' | 'technology'
|
|
2138
|
+
): Promise<ReviewTrend[]> {
|
|
2139
|
+
return this.reviewAnalyticsService.getReviewTrends(dateRange, period, filters, entityType);
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
|