@blackcode_sa/metaestetics-api 1.13.5 → 1.13.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/dist/admin/index.d.mts +20 -1
  2. package/dist/admin/index.d.ts +20 -1
  3. package/dist/admin/index.js +217 -1
  4. package/dist/admin/index.mjs +217 -1
  5. package/dist/index.d.mts +26 -3
  6. package/dist/index.d.ts +26 -3
  7. package/dist/index.js +168 -6
  8. package/dist/index.mjs +168 -6
  9. package/package.json +121 -121
  10. package/src/__mocks__/firstore.ts +10 -10
  11. package/src/admin/aggregation/README.md +79 -79
  12. package/src/admin/aggregation/appointment/README.md +128 -128
  13. package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +1984 -1984
  14. package/src/admin/aggregation/appointment/index.ts +1 -1
  15. package/src/admin/aggregation/clinic/README.md +52 -52
  16. package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +966 -703
  17. package/src/admin/aggregation/clinic/index.ts +1 -1
  18. package/src/admin/aggregation/forms/README.md +13 -13
  19. package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
  20. package/src/admin/aggregation/forms/index.ts +1 -1
  21. package/src/admin/aggregation/index.ts +8 -8
  22. package/src/admin/aggregation/patient/README.md +27 -27
  23. package/src/admin/aggregation/patient/index.ts +1 -1
  24. package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
  25. package/src/admin/aggregation/practitioner/README.md +42 -42
  26. package/src/admin/aggregation/practitioner/index.ts +1 -1
  27. package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
  28. package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
  29. package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
  30. package/src/admin/aggregation/procedure/README.md +43 -43
  31. package/src/admin/aggregation/procedure/index.ts +1 -1
  32. package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
  33. package/src/admin/aggregation/reviews/index.ts +1 -1
  34. package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +689 -689
  35. package/src/admin/analytics/analytics.admin.service.ts +278 -278
  36. package/src/admin/analytics/index.ts +2 -2
  37. package/src/admin/booking/README.md +125 -125
  38. package/src/admin/booking/booking.admin.ts +1037 -1037
  39. package/src/admin/booking/booking.calculator.ts +712 -712
  40. package/src/admin/booking/booking.types.ts +59 -59
  41. package/src/admin/booking/index.ts +3 -3
  42. package/src/admin/booking/timezones-problem.md +185 -185
  43. package/src/admin/calendar/README.md +7 -7
  44. package/src/admin/calendar/calendar.admin.service.ts +345 -345
  45. package/src/admin/calendar/index.ts +1 -1
  46. package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
  47. package/src/admin/documentation-templates/index.ts +1 -1
  48. package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
  49. package/src/admin/free-consultation/index.ts +1 -1
  50. package/src/admin/index.ts +81 -81
  51. package/src/admin/logger/index.ts +78 -78
  52. package/src/admin/mailing/README.md +95 -95
  53. package/src/admin/mailing/appointment/appointment.mailing.service.ts +732 -732
  54. package/src/admin/mailing/appointment/index.ts +1 -1
  55. package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
  56. package/src/admin/mailing/base.mailing.service.ts +208 -208
  57. package/src/admin/mailing/index.ts +3 -3
  58. package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
  59. package/src/admin/mailing/practitionerInvite/index.ts +2 -2
  60. package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
  61. package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
  62. package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
  63. package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
  64. package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
  65. package/src/admin/notifications/index.ts +1 -1
  66. package/src/admin/notifications/notifications.admin.ts +710 -710
  67. package/src/admin/requirements/README.md +128 -128
  68. package/src/admin/requirements/index.ts +1 -1
  69. package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
  70. package/src/admin/users/index.ts +1 -1
  71. package/src/admin/users/user-profile.admin.ts +405 -405
  72. package/src/backoffice/constants/certification.constants.ts +13 -13
  73. package/src/backoffice/constants/index.ts +1 -1
  74. package/src/backoffice/errors/backoffice.errors.ts +181 -181
  75. package/src/backoffice/errors/index.ts +1 -1
  76. package/src/backoffice/expo-safe/README.md +26 -26
  77. package/src/backoffice/expo-safe/index.ts +41 -41
  78. package/src/backoffice/index.ts +5 -5
  79. package/src/backoffice/services/FIXES_README.md +102 -102
  80. package/src/backoffice/services/README.md +57 -57
  81. package/src/backoffice/services/analytics.service.proposal.md +863 -863
  82. package/src/backoffice/services/analytics.service.summary.md +143 -143
  83. package/src/backoffice/services/brand.service.ts +256 -256
  84. package/src/backoffice/services/category.service.ts +384 -384
  85. package/src/backoffice/services/constants.service.ts +385 -385
  86. package/src/backoffice/services/documentation-template.service.ts +202 -202
  87. package/src/backoffice/services/index.ts +10 -10
  88. package/src/backoffice/services/migrate-products.ts +116 -116
  89. package/src/backoffice/services/product.service.ts +553 -553
  90. package/src/backoffice/services/requirement.service.ts +235 -235
  91. package/src/backoffice/services/subcategory.service.ts +461 -461
  92. package/src/backoffice/services/technology.service.ts +1151 -1151
  93. package/src/backoffice/types/README.md +12 -12
  94. package/src/backoffice/types/admin-constants.types.ts +69 -69
  95. package/src/backoffice/types/brand.types.ts +29 -29
  96. package/src/backoffice/types/category.types.ts +67 -67
  97. package/src/backoffice/types/documentation-templates.types.ts +28 -28
  98. package/src/backoffice/types/index.ts +10 -10
  99. package/src/backoffice/types/procedure-product.types.ts +38 -38
  100. package/src/backoffice/types/product.types.ts +240 -240
  101. package/src/backoffice/types/requirement.types.ts +63 -63
  102. package/src/backoffice/types/static/README.md +18 -18
  103. package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
  104. package/src/backoffice/types/static/certification.types.ts +37 -37
  105. package/src/backoffice/types/static/contraindication.types.ts +19 -19
  106. package/src/backoffice/types/static/index.ts +6 -6
  107. package/src/backoffice/types/static/pricing.types.ts +16 -16
  108. package/src/backoffice/types/static/procedure-family.types.ts +14 -14
  109. package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
  110. package/src/backoffice/types/subcategory.types.ts +34 -34
  111. package/src/backoffice/types/technology.types.ts +168 -168
  112. package/src/backoffice/validations/index.ts +1 -1
  113. package/src/backoffice/validations/schemas.ts +164 -164
  114. package/src/config/__mocks__/firebase.ts +99 -99
  115. package/src/config/firebase.ts +78 -78
  116. package/src/config/index.ts +9 -9
  117. package/src/errors/auth.error.ts +6 -6
  118. package/src/errors/auth.errors.ts +211 -200
  119. package/src/errors/clinic.errors.ts +32 -32
  120. package/src/errors/firebase.errors.ts +47 -47
  121. package/src/errors/user.errors.ts +99 -99
  122. package/src/index.backup.ts +407 -407
  123. package/src/index.ts +6 -6
  124. package/src/locales/en.ts +31 -31
  125. package/src/recommender/admin/index.ts +1 -1
  126. package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
  127. package/src/recommender/front/index.ts +1 -1
  128. package/src/recommender/front/services/onboarding.service.ts +5 -5
  129. package/src/recommender/front/services/recommender.service.ts +3 -3
  130. package/src/recommender/index.ts +1 -1
  131. package/src/services/PATIENTAUTH.MD +197 -197
  132. package/src/services/README.md +106 -106
  133. package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
  134. package/src/services/__tests__/auth/auth.setup.ts +293 -293
  135. package/src/services/__tests__/auth.service.test.ts +346 -346
  136. package/src/services/__tests__/base.service.test.ts +77 -77
  137. package/src/services/__tests__/user.service.test.ts +528 -528
  138. package/src/services/analytics/ARCHITECTURE.md +199 -199
  139. package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -225
  140. package/src/services/analytics/GROUPED_ANALYTICS.md +501 -501
  141. package/src/services/analytics/QUICK_START.md +393 -393
  142. package/src/services/analytics/README.md +304 -304
  143. package/src/services/analytics/SUMMARY.md +141 -141
  144. package/src/services/analytics/TRENDS.md +380 -380
  145. package/src/services/analytics/USAGE_GUIDE.md +518 -518
  146. package/src/services/analytics/analytics-cloud.service.ts +222 -222
  147. package/src/services/analytics/analytics.service.ts +2142 -2142
  148. package/src/services/analytics/index.ts +4 -4
  149. package/src/services/analytics/review-analytics.service.ts +941 -941
  150. package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -138
  151. package/src/services/analytics/utils/cost-calculation.utils.ts +182 -182
  152. package/src/services/analytics/utils/grouping.utils.ts +434 -434
  153. package/src/services/analytics/utils/stored-analytics.utils.ts +347 -347
  154. package/src/services/analytics/utils/time-calculation.utils.ts +186 -186
  155. package/src/services/analytics/utils/trend-calculation.utils.ts +200 -200
  156. package/src/services/appointment/README.md +17 -17
  157. package/src/services/appointment/appointment.service.ts +2558 -2558
  158. package/src/services/appointment/index.ts +1 -1
  159. package/src/services/appointment/utils/appointment.utils.ts +552 -552
  160. package/src/services/appointment/utils/extended-procedure.utils.ts +314 -314
  161. package/src/services/appointment/utils/form-initialization.utils.ts +225 -225
  162. package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
  163. package/src/services/appointment/utils/zone-management.utils.ts +353 -353
  164. package/src/services/appointment/utils/zone-photo.utils.ts +152 -152
  165. package/src/services/auth/auth.service.ts +1043 -989
  166. package/src/services/auth/auth.v2.service.ts +961 -961
  167. package/src/services/auth/index.ts +7 -7
  168. package/src/services/auth/utils/error.utils.ts +90 -90
  169. package/src/services/auth/utils/firebase.utils.ts +49 -49
  170. package/src/services/auth/utils/index.ts +21 -21
  171. package/src/services/auth/utils/practitioner.utils.ts +125 -125
  172. package/src/services/base.service.ts +41 -41
  173. package/src/services/calendar/calendar.service.ts +1077 -1077
  174. package/src/services/calendar/calendar.v2.service.ts +1683 -1683
  175. package/src/services/calendar/calendar.v3.service.ts +313 -313
  176. package/src/services/calendar/externalCalendar.service.ts +178 -178
  177. package/src/services/calendar/index.ts +5 -5
  178. package/src/services/calendar/synced-calendars.service.ts +743 -743
  179. package/src/services/calendar/utils/appointment.utils.ts +265 -265
  180. package/src/services/calendar/utils/calendar-event.utils.ts +646 -646
  181. package/src/services/calendar/utils/clinic.utils.ts +237 -237
  182. package/src/services/calendar/utils/docs.utils.ts +157 -157
  183. package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
  184. package/src/services/calendar/utils/index.ts +8 -8
  185. package/src/services/calendar/utils/patient.utils.ts +198 -198
  186. package/src/services/calendar/utils/practitioner.utils.ts +221 -221
  187. package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
  188. package/src/services/clinic/README.md +204 -204
  189. package/src/services/clinic/__tests__/clinic-admin.service.test.ts +287 -287
  190. package/src/services/clinic/__tests__/clinic-group.service.test.ts +352 -352
  191. package/src/services/clinic/__tests__/clinic.service.test.ts +354 -354
  192. package/src/services/clinic/billing-transactions.service.ts +217 -217
  193. package/src/services/clinic/clinic-admin.service.ts +202 -202
  194. package/src/services/clinic/clinic-group.service.ts +310 -310
  195. package/src/services/clinic/clinic.service.ts +708 -708
  196. package/src/services/clinic/index.ts +5 -5
  197. package/src/services/clinic/practitioner-invite.service.ts +519 -519
  198. package/src/services/clinic/utils/admin.utils.ts +551 -551
  199. package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
  200. package/src/services/clinic/utils/clinic.utils.ts +949 -949
  201. package/src/services/clinic/utils/filter.utils.d.ts +23 -23
  202. package/src/services/clinic/utils/filter.utils.ts +446 -446
  203. package/src/services/clinic/utils/index.ts +11 -11
  204. package/src/services/clinic/utils/photos.utils.ts +188 -188
  205. package/src/services/clinic/utils/search.utils.ts +84 -84
  206. package/src/services/clinic/utils/tag.utils.ts +124 -124
  207. package/src/services/documentation-templates/documentation-template.service.ts +537 -537
  208. package/src/services/documentation-templates/filled-document.service.ts +587 -587
  209. package/src/services/documentation-templates/index.ts +2 -2
  210. package/src/services/index.ts +14 -14
  211. package/src/services/media/index.ts +1 -1
  212. package/src/services/media/media.service.ts +418 -418
  213. package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
  214. package/src/services/notifications/index.ts +1 -1
  215. package/src/services/notifications/notification.service.ts +215 -215
  216. package/src/services/patient/README.md +48 -48
  217. package/src/services/patient/To-Do.md +43 -43
  218. package/src/services/patient/__tests__/patient.service.test.ts +294 -294
  219. package/src/services/patient/index.ts +2 -2
  220. package/src/services/patient/patient.service.ts +883 -883
  221. package/src/services/patient/patientRequirements.service.ts +285 -285
  222. package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
  223. package/src/services/patient/utils/clinic.utils.ts +80 -80
  224. package/src/services/patient/utils/docs.utils.ts +142 -142
  225. package/src/services/patient/utils/index.ts +9 -9
  226. package/src/services/patient/utils/location.utils.ts +126 -126
  227. package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
  228. package/src/services/patient/utils/medical.utils.ts +458 -458
  229. package/src/services/patient/utils/practitioner.utils.ts +260 -260
  230. package/src/services/patient/utils/profile.utils.ts +510 -510
  231. package/src/services/patient/utils/sensitive.utils.ts +260 -260
  232. package/src/services/patient/utils/token.utils.ts +211 -211
  233. package/src/services/practitioner/README.md +145 -145
  234. package/src/services/practitioner/index.ts +1 -1
  235. package/src/services/practitioner/practitioner.service.ts +1799 -1742
  236. package/src/services/procedure/README.md +163 -163
  237. package/src/services/procedure/index.ts +1 -1
  238. package/src/services/procedure/procedure.service.ts +2307 -2200
  239. package/src/services/reviews/index.ts +1 -1
  240. package/src/services/reviews/reviews.service.ts +734 -734
  241. package/src/services/user/index.ts +1 -1
  242. package/src/services/user/user.service.ts +489 -489
  243. package/src/services/user/user.v2.service.ts +466 -466
  244. package/src/types/analytics/analytics.types.ts +597 -597
  245. package/src/types/analytics/grouped-analytics.types.ts +173 -173
  246. package/src/types/analytics/index.ts +4 -4
  247. package/src/types/analytics/stored-analytics.types.ts +137 -137
  248. package/src/types/appointment/index.ts +480 -480
  249. package/src/types/calendar/index.ts +258 -258
  250. package/src/types/calendar/synced-calendar.types.ts +66 -66
  251. package/src/types/clinic/index.ts +498 -498
  252. package/src/types/clinic/practitioner-invite.types.ts +91 -91
  253. package/src/types/clinic/preferences.types.ts +159 -159
  254. package/src/types/clinic/to-do +3 -3
  255. package/src/types/documentation-templates/index.ts +308 -308
  256. package/src/types/index.ts +47 -47
  257. package/src/types/notifications/README.md +77 -77
  258. package/src/types/notifications/index.ts +286 -286
  259. package/src/types/patient/aesthetic-analysis.types.ts +66 -66
  260. package/src/types/patient/allergies.ts +58 -58
  261. package/src/types/patient/index.ts +275 -275
  262. package/src/types/patient/medical-info.types.ts +152 -152
  263. package/src/types/patient/patient-requirements.ts +92 -92
  264. package/src/types/patient/token.types.ts +61 -61
  265. package/src/types/practitioner/index.ts +206 -206
  266. package/src/types/procedure/index.ts +181 -181
  267. package/src/types/profile/index.ts +39 -39
  268. package/src/types/reviews/index.ts +132 -132
  269. package/src/types/tz-lookup.d.ts +4 -4
  270. package/src/types/user/index.ts +38 -38
  271. package/src/utils/TIMESTAMPS.md +176 -176
  272. package/src/utils/TimestampUtils.ts +241 -241
  273. package/src/utils/index.ts +1 -1
  274. package/src/validations/appointment.schema.ts +574 -574
  275. package/src/validations/calendar.schema.ts +225 -225
  276. package/src/validations/clinic.schema.ts +494 -494
  277. package/src/validations/common.schema.ts +25 -25
  278. package/src/validations/documentation-templates/index.ts +1 -1
  279. package/src/validations/documentation-templates/template.schema.ts +220 -220
  280. package/src/validations/documentation-templates.schema.ts +10 -10
  281. package/src/validations/index.ts +20 -20
  282. package/src/validations/media.schema.ts +10 -10
  283. package/src/validations/notification.schema.ts +90 -90
  284. package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
  285. package/src/validations/patient/medical-info.schema.ts +125 -125
  286. package/src/validations/patient/patient-requirements.schema.ts +84 -84
  287. package/src/validations/patient/token.schema.ts +29 -29
  288. package/src/validations/patient.schema.ts +217 -217
  289. package/src/validations/practitioner.schema.ts +222 -222
  290. package/src/validations/procedure-product.schema.ts +41 -41
  291. package/src/validations/procedure.schema.ts +124 -124
  292. package/src/validations/profile-info.schema.ts +41 -41
  293. package/src/validations/reviews.schema.ts +195 -195
  294. package/src/validations/schemas.ts +104 -104
  295. 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
+