@blackcode_sa/metaestetics-api 1.13.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -67,7 +67,11 @@ export function calculateAppointmentCost(appointment: Appointment): {
67
67
  /**
68
68
  * Calculates total revenue from an array of appointments
69
69
  *
70
- * @param appointments - Array of appointments
70
+ * IMPORTANT: This function should only be called with COMPLETED appointments.
71
+ * Only completed procedures generate revenue. Confirmed, pending, canceled, and no-show
72
+ * appointments should be filtered out before calling this function.
73
+ *
74
+ * @param appointments - Array of appointments (should be filtered to COMPLETED only)
71
75
  * @returns Total revenue and currency
72
76
  */
73
77
  export function calculateTotalRevenue(appointments: Appointment[]): {
@@ -96,8 +100,19 @@ export function calculateTotalRevenue(appointments: Appointment[]): {
96
100
  /**
97
101
  * Extracts product usage from appointment metadata
98
102
  *
99
- * @param appointment - The appointment to extract products from
100
- * @returns Array of product usage data
103
+ * IMPORTANT: This function should only be called with COMPLETED appointments.
104
+ * Products are only considered "used" when the procedure has been completed.
105
+ * Only completed procedures generate product usage and revenue.
106
+ *
107
+ * NOTE ON PRICING:
108
+ * - Price overrides (priceOverrideAmount) are always applied if available
109
+ * - Subtotal is recalculated to ensure price overrides are reflected
110
+ * - Product revenue shows SUBTOTAL (before tax) - tax is applied at appointment level
111
+ * - Tax is included in appointment revenue (finalbilling.finalPrice) but not in product revenue
112
+ * - Product subtotals should sum to finalbilling.subtotalAll (before tax)
113
+ *
114
+ * @param appointment - The appointment to extract products from (should be COMPLETED)
115
+ * @returns Array of product usage data with subtotal (before tax)
101
116
  */
102
117
  export function extractProductUsage(appointment: Appointment): Array<{
103
118
  productId: string;
@@ -131,9 +146,22 @@ export function extractProductUsage(appointment: Appointment): Array<{
131
146
  Object.values(zonesData).forEach(items => {
132
147
  items.forEach(item => {
133
148
  if (item.type === 'item' && item.productId) {
149
+ // Always use priceOverrideAmount if available, otherwise use price
150
+ // This ensures price overrides are properly applied to product calculations
134
151
  const price = item.priceOverrideAmount || item.price || 0;
135
152
  const quantity = item.quantity || 1;
136
- const subtotal = item.subtotal || price * quantity;
153
+
154
+ // Always recalculate subtotal based on the actual price (with override)
155
+ // This ensures price overrides are reflected in product revenue
156
+ // Use stored subtotal only if it matches the calculated value (to handle rounding)
157
+ const calculatedSubtotal = price * quantity;
158
+ const storedSubtotal = item.subtotal || 0;
159
+
160
+ // Use stored subtotal if it's close to calculated (within 0.01 for rounding differences)
161
+ // Otherwise recalculate to ensure price override is applied correctly
162
+ const subtotal = Math.abs(storedSubtotal - calculatedSubtotal) < 0.01
163
+ ? storedSubtotal
164
+ : calculatedSubtotal;
137
165
 
138
166
  products.push({
139
167
  productId: item.productId,
@@ -101,6 +101,9 @@ export function groupAppointmentsByEntity(
101
101
 
102
102
  /**
103
103
  * Calculates grouped revenue metrics
104
+ *
105
+ * IMPORTANT: Only COMPLETED appointments are included in revenue calculations.
106
+ * Confirmed, pending, canceled, and no-show appointments are excluded from financial metrics.
104
107
  */
105
108
  export function calculateGroupedRevenueMetrics(
106
109
  appointments: Appointment[],
@@ -139,6 +142,15 @@ export function calculateGroupedRevenueMetrics(
139
142
  }
140
143
  });
141
144
 
145
+ // Get practitioner info when grouping by procedure
146
+ let practitionerId: string | undefined;
147
+ let practitionerName: string | undefined;
148
+ if (entityType === 'procedure' && entityAppointments.length > 0) {
149
+ const firstAppointment = entityAppointments[0];
150
+ practitionerId = firstAppointment.practitionerId;
151
+ practitionerName = firstAppointment.practitionerInfo?.name;
152
+ }
153
+
142
154
  return {
143
155
  entityId,
144
156
  entityName: data.name,
@@ -153,12 +165,18 @@ export function calculateGroupedRevenueMetrics(
153
165
  refundedRevenue,
154
166
  totalTax,
155
167
  totalSubtotal,
168
+ ...(practitionerId && { practitionerId }),
169
+ ...(practitionerName && { practitionerName }),
156
170
  };
157
171
  });
158
172
  }
159
173
 
160
174
  /**
161
175
  * Calculates grouped product usage metrics
176
+ *
177
+ * IMPORTANT: Only COMPLETED appointments are included in product usage calculations.
178
+ * Products are only considered "used" when the procedure has been completed.
179
+ * Confirmed, pending, canceled, and no-show appointments are excluded from product metrics.
162
180
  */
163
181
  export function calculateGroupedProductUsageMetrics(
164
182
  appointments: Appointment[],
@@ -220,6 +238,15 @@ export function calculateGroupedProductUsageMetrics(
220
238
  const totalProductRevenue = topProducts.reduce((sum, p) => sum + p.totalRevenue, 0);
221
239
  const totalProductQuantity = topProducts.reduce((sum, p) => sum + p.totalQuantity, 0);
222
240
 
241
+ // Get practitioner info when grouping by procedure
242
+ let practitionerId: string | undefined;
243
+ let practitionerName: string | undefined;
244
+ if (entityType === 'procedure' && entityAppointments.length > 0) {
245
+ const firstAppointment = entityAppointments[0];
246
+ practitionerId = firstAppointment.practitionerId;
247
+ practitionerName = firstAppointment.practitionerInfo?.name;
248
+ }
249
+
223
250
  return {
224
251
  entityId,
225
252
  entityName: data.name,
@@ -231,6 +258,8 @@ export function calculateGroupedProductUsageMetrics(
231
258
  averageProductsPerAppointment:
232
259
  entityCompleted.length > 0 ? productMap.size / entityCompleted.length : 0,
233
260
  topProducts,
261
+ ...(practitionerId && { practitionerId }),
262
+ ...(practitionerName && { practitionerName }),
234
263
  };
235
264
  });
236
265
  }
@@ -253,6 +282,15 @@ export function calculateGroupedTimeEfficiencyMetrics(
253
282
 
254
283
  const timeMetrics = calculateAverageTimeMetrics(entityCompleted);
255
284
 
285
+ // Get practitioner info when grouping by procedure
286
+ let practitionerId: string | undefined;
287
+ let practitionerName: string | undefined;
288
+ if (entityType === 'procedure' && entityAppointments.length > 0) {
289
+ const firstAppointment = entityAppointments[0];
290
+ practitionerId = firstAppointment.practitionerId;
291
+ practitionerName = firstAppointment.practitionerInfo?.name;
292
+ }
293
+
256
294
  return {
257
295
  entityId,
258
296
  entityName: data.name,
@@ -266,6 +304,8 @@ export function calculateGroupedTimeEfficiencyMetrics(
266
304
  totalUnderutilization: timeMetrics.totalUnderutilization,
267
305
  averageOverrun: timeMetrics.averageOverrun,
268
306
  averageUnderutilization: timeMetrics.averageUnderutilization,
307
+ ...(practitionerId && { practitionerId }),
308
+ ...(practitionerName && { practitionerName }),
269
309
  };
270
310
  });
271
311
  }
@@ -0,0 +1,200 @@
1
+ import { Appointment } from '../../../types/appointment';
2
+ import { Timestamp } from 'firebase/firestore';
3
+
4
+ /**
5
+ * Trend period type
6
+ */
7
+ export type TrendPeriod = 'week' | 'month' | 'quarter' | 'year';
8
+
9
+ /**
10
+ * Period information for trend grouping
11
+ */
12
+ export interface PeriodInfo {
13
+ period: string; // e.g., "2024-W01", "2024-01", "2024-Q1", "2024"
14
+ startDate: Date;
15
+ endDate: Date;
16
+ }
17
+
18
+ /**
19
+ * Calculates the start and end dates for a given period
20
+ */
21
+ export function getPeriodDates(date: Date, period: TrendPeriod): PeriodInfo {
22
+ const year = date.getFullYear();
23
+ const month = date.getMonth();
24
+ const day = date.getDate();
25
+
26
+ let startDate: Date;
27
+ let endDate: Date;
28
+ let periodString: string;
29
+
30
+ switch (period) {
31
+ case 'week': {
32
+ // Get Monday of the week
33
+ const dayOfWeek = date.getDay();
34
+ const diff = date.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
35
+ startDate = new Date(year, month, diff);
36
+ startDate.setHours(0, 0, 0, 0);
37
+
38
+ endDate = new Date(startDate);
39
+ endDate.setDate(endDate.getDate() + 6);
40
+ endDate.setHours(23, 59, 59, 999);
41
+
42
+ // ISO week format: YYYY-Www
43
+ const weekNumber = getWeekNumber(date);
44
+ periodString = `${year}-W${weekNumber.toString().padStart(2, '0')}`;
45
+ break;
46
+ }
47
+
48
+ case 'month': {
49
+ startDate = new Date(year, month, 1);
50
+ startDate.setHours(0, 0, 0, 0);
51
+
52
+ endDate = new Date(year, month + 1, 0);
53
+ endDate.setHours(23, 59, 59, 999);
54
+
55
+ periodString = `${year}-${(month + 1).toString().padStart(2, '0')}`;
56
+ break;
57
+ }
58
+
59
+ case 'quarter': {
60
+ const quarter = Math.floor(month / 3);
61
+ const quarterStartMonth = quarter * 3;
62
+
63
+ startDate = new Date(year, quarterStartMonth, 1);
64
+ startDate.setHours(0, 0, 0, 0);
65
+
66
+ endDate = new Date(year, quarterStartMonth + 3, 0);
67
+ endDate.setHours(23, 59, 59, 999);
68
+
69
+ periodString = `${year}-Q${quarter + 1}`;
70
+ break;
71
+ }
72
+
73
+ case 'year': {
74
+ startDate = new Date(year, 0, 1);
75
+ startDate.setHours(0, 0, 0, 0);
76
+
77
+ endDate = new Date(year, 11, 31);
78
+ endDate.setHours(23, 59, 59, 999);
79
+
80
+ periodString = `${year}`;
81
+ break;
82
+ }
83
+ }
84
+
85
+ return {
86
+ period: periodString,
87
+ startDate,
88
+ endDate,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Gets ISO week number for a date
94
+ */
95
+ function getWeekNumber(date: Date): number {
96
+ const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
97
+ const dayNum = d.getUTCDay() || 7;
98
+ d.setUTCDate(d.getUTCDate() + 4 - dayNum);
99
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
100
+ return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
101
+ }
102
+
103
+ /**
104
+ * Groups appointments by trend period
105
+ */
106
+ export function groupAppointmentsByPeriod(
107
+ appointments: Appointment[],
108
+ period: TrendPeriod,
109
+ ): Map<string, Appointment[]> {
110
+ const periodMap = new Map<string, Appointment[]>();
111
+
112
+ appointments.forEach(appointment => {
113
+ const appointmentDate = appointment.appointmentStartTime.toDate();
114
+ const periodInfo = getPeriodDates(appointmentDate, period);
115
+ const periodKey = periodInfo.period;
116
+
117
+ if (!periodMap.has(periodKey)) {
118
+ periodMap.set(periodKey, []);
119
+ }
120
+ periodMap.get(periodKey)!.push(appointment);
121
+ });
122
+
123
+ return periodMap;
124
+ }
125
+
126
+ /**
127
+ * Generates all periods between start and end date for a given period type
128
+ */
129
+ export function generatePeriods(
130
+ startDate: Date,
131
+ endDate: Date,
132
+ period: TrendPeriod,
133
+ ): PeriodInfo[] {
134
+ const periods: PeriodInfo[] = [];
135
+ const current = new Date(startDate);
136
+
137
+ while (current <= endDate) {
138
+ const periodInfo = getPeriodDates(current, period);
139
+
140
+ // Only add if period overlaps with our date range
141
+ if (periodInfo.endDate >= startDate && periodInfo.startDate <= endDate) {
142
+ periods.push(periodInfo);
143
+ }
144
+
145
+ // Move to next period
146
+ switch (period) {
147
+ case 'week':
148
+ current.setDate(current.getDate() + 7);
149
+ break;
150
+ case 'month':
151
+ current.setMonth(current.getMonth() + 1);
152
+ break;
153
+ case 'quarter':
154
+ current.setMonth(current.getMonth() + 3);
155
+ break;
156
+ case 'year':
157
+ current.setFullYear(current.getFullYear() + 1);
158
+ break;
159
+ }
160
+ }
161
+
162
+ return periods.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
163
+ }
164
+
165
+ /**
166
+ * Calculates percentage change between two values
167
+ * @param current - Current value
168
+ * @param previous - Previous value
169
+ * @returns Percentage change (positive = increase, negative = decrease)
170
+ */
171
+ export function calculatePercentageChange(current: number, previous: number): number {
172
+ if (previous === 0) {
173
+ return current > 0 ? 100 : 0;
174
+ }
175
+ return ((current - previous) / previous) * 100;
176
+ }
177
+
178
+ /**
179
+ * Gets trend direction and percentage change
180
+ */
181
+ export interface TrendChange {
182
+ value: number;
183
+ previousValue: number;
184
+ percentageChange: number;
185
+ direction: 'up' | 'down' | 'stable';
186
+ }
187
+
188
+ export function getTrendChange(current: number, previous: number): TrendChange {
189
+ const percentageChange = calculatePercentageChange(current, previous);
190
+ const direction: 'up' | 'down' | 'stable' =
191
+ percentageChange > 0.01 ? 'up' : percentageChange < -0.01 ? 'down' : 'stable';
192
+
193
+ return {
194
+ value: current,
195
+ previousValue: previous,
196
+ percentageChange: Math.abs(percentageChange),
197
+ direction,
198
+ };
199
+ }
200
+
@@ -1833,6 +1833,12 @@ export class AppointmentService extends BaseService {
1833
1833
  finalizationNotes: null,
1834
1834
  };
1835
1835
 
1836
+ // Update payment status if billing data exists but status is NOT_APPLICABLE
1837
+ // This handles cases where appointment was created with price 0 but billing was added later
1838
+ const shouldUpdatePaymentStatus =
1839
+ finalbilling.finalPrice > 0 &&
1840
+ appointment.paymentStatus === PaymentStatus.NOT_APPLICABLE;
1841
+
1836
1842
  const updateData: UpdateAppointmentData = {
1837
1843
  metadata: {
1838
1844
  selectedZones: currentMetadata.selectedZones,
@@ -1848,6 +1854,9 @@ export class AppointmentService extends BaseService {
1848
1854
  finalbilling,
1849
1855
  finalizationNotes: currentMetadata.finalizationNotes,
1850
1856
  },
1857
+ ...(shouldUpdatePaymentStatus && {
1858
+ paymentStatus: PaymentStatus.UNPAID,
1859
+ }),
1851
1860
  updatedAt: serverTimestamp(),
1852
1861
  };
1853
1862
 
@@ -1088,6 +1088,14 @@ export class ProcedureService extends BaseService {
1088
1088
  console.log('[PROCEDURE_SERVICE] Strategy 1: Trying nameLower search');
1089
1089
  const searchTerm = filters.nameSearch.trim().toLowerCase();
1090
1090
  const constraints = getBaseConstraints();
1091
+
1092
+ // Check if we have nested field filters that might conflict with orderBy
1093
+ const hasNestedFilters = !!(filters.procedureTechnology || filters.procedureCategory || filters.procedureSubcategory);
1094
+
1095
+ if (hasNestedFilters) {
1096
+ console.log('[PROCEDURE_SERVICE] Strategy 1: Has nested filters, will apply client-side after query');
1097
+ }
1098
+
1091
1099
  constraints.push(where('nameLower', '>=', searchTerm));
1092
1100
  constraints.push(where('nameLower', '<=', searchTerm + '\uf8ff'));
1093
1101
  constraints.push(orderBy('nameLower'));
@@ -1105,9 +1113,15 @@ export class ProcedureService extends BaseService {
1105
1113
 
1106
1114
  const q = query(collection(this.db, PROCEDURES_COLLECTION), ...constraints);
1107
1115
  const querySnapshot = await getDocs(q);
1108
- const procedures = querySnapshot.docs.map(
1116
+ let procedures = querySnapshot.docs.map(
1109
1117
  doc => ({ ...doc.data(), id: doc.id } as Procedure),
1110
1118
  );
1119
+
1120
+ // Apply client-side filters for nested fields if needed
1121
+ if (hasNestedFilters) {
1122
+ procedures = this.applyInMemoryFilters(procedures, filters);
1123
+ }
1124
+
1111
1125
  const lastDoc =
1112
1126
  querySnapshot.docs.length > 0
1113
1127
  ? querySnapshot.docs[querySnapshot.docs.length - 1]
@@ -1131,6 +1145,14 @@ export class ProcedureService extends BaseService {
1131
1145
  console.log('[PROCEDURE_SERVICE] Strategy 2: Trying name field search');
1132
1146
  const searchTerm = filters.nameSearch.trim().toLowerCase();
1133
1147
  const constraints = getBaseConstraints();
1148
+
1149
+ // Check if we have nested field filters that might conflict with orderBy
1150
+ const hasNestedFilters = !!(filters.procedureTechnology || filters.procedureCategory || filters.procedureSubcategory);
1151
+
1152
+ if (hasNestedFilters) {
1153
+ console.log('[PROCEDURE_SERVICE] Strategy 2: Has nested filters, will apply client-side after query');
1154
+ }
1155
+
1134
1156
  constraints.push(where('name', '>=', searchTerm));
1135
1157
  constraints.push(where('name', '<=', searchTerm + '\uf8ff'));
1136
1158
  constraints.push(orderBy('name'));
@@ -1148,9 +1170,15 @@ export class ProcedureService extends BaseService {
1148
1170
 
1149
1171
  const q = query(collection(this.db, PROCEDURES_COLLECTION), ...constraints);
1150
1172
  const querySnapshot = await getDocs(q);
1151
- const procedures = querySnapshot.docs.map(
1173
+ let procedures = querySnapshot.docs.map(
1152
1174
  doc => ({ ...doc.data(), id: doc.id } as Procedure),
1153
1175
  );
1176
+
1177
+ // Apply client-side filters for nested fields if needed
1178
+ if (hasNestedFilters) {
1179
+ procedures = this.applyInMemoryFilters(procedures, filters);
1180
+ }
1181
+
1154
1182
  const lastDoc =
1155
1183
  querySnapshot.docs.length > 0
1156
1184
  ? querySnapshot.docs[querySnapshot.docs.length - 1]
@@ -1169,11 +1197,66 @@ export class ProcedureService extends BaseService {
1169
1197
  }
1170
1198
 
1171
1199
  // Strategy 3: orderBy createdAt with client-side filtering
1200
+ // NOTE: This strategy excludes nested field filters (technology.id, category.id, subcategory.id)
1201
+ // from Firestore query because Firestore doesn't support orderBy on different field
1202
+ // when using where on nested fields without a composite index.
1203
+ // These filters are applied client-side instead.
1172
1204
  try {
1173
1205
  console.log(
1174
1206
  '[PROCEDURE_SERVICE] Strategy 3: Using createdAt orderBy with client-side filtering',
1207
+ {
1208
+ procedureTechnology: filters.procedureTechnology,
1209
+ hasTechnologyFilter: !!filters.procedureTechnology,
1210
+ },
1211
+ );
1212
+
1213
+ // Build constraints WITHOUT nested field filters (these will be applied client-side)
1214
+ const constraints: QueryConstraint[] = [];
1215
+
1216
+ // Active status filter
1217
+ if (filters.isActive !== undefined) {
1218
+ constraints.push(where('isActive', '==', filters.isActive));
1219
+ } else {
1220
+ constraints.push(where('isActive', '==', true));
1221
+ }
1222
+
1223
+ // Only include non-nested field filters in Firestore query
1224
+ if (filters.procedureFamily) {
1225
+ constraints.push(where('family', '==', filters.procedureFamily));
1226
+ }
1227
+ if (filters.practitionerId) {
1228
+ constraints.push(where('practitionerId', '==', filters.practitionerId));
1229
+ }
1230
+ if (filters.clinicId) {
1231
+ constraints.push(where('clinicBranchId', '==', filters.clinicId));
1232
+ }
1233
+ if (filters.minPrice !== undefined) {
1234
+ constraints.push(where('price', '>=', filters.minPrice));
1235
+ }
1236
+ if (filters.maxPrice !== undefined) {
1237
+ constraints.push(where('price', '<=', filters.maxPrice));
1238
+ }
1239
+ if (filters.minRating !== undefined) {
1240
+ constraints.push(where('reviewInfo.averageRating', '>=', filters.minRating));
1241
+ }
1242
+ if (filters.maxRating !== undefined) {
1243
+ constraints.push(where('reviewInfo.averageRating', '<=', filters.maxRating));
1244
+ }
1245
+ if (filters.treatmentBenefits && filters.treatmentBenefits.length > 0) {
1246
+ const benefitIdsToMatch = filters.treatmentBenefits;
1247
+ constraints.push(where('treatmentBenefitIds', 'array-contains-any', benefitIdsToMatch));
1248
+ }
1249
+
1250
+ // NOTE: We intentionally EXCLUDE these nested field filters from Firestore query:
1251
+ // - filters.procedureTechnology (technology.id)
1252
+ // - filters.procedureCategory (category.id)
1253
+ // - filters.procedureSubcategory (subcategory.id)
1254
+ // These will be applied client-side in applyInMemoryFilters
1255
+
1256
+ console.log(
1257
+ '[PROCEDURE_SERVICE] Strategy 3 Firestore constraints (nested filters excluded):',
1258
+ constraints.map(c => (c as any).fieldPath || 'unknown'),
1175
1259
  );
1176
- const constraints = getBaseConstraints();
1177
1260
  constraints.push(orderBy('createdAt', 'desc'));
1178
1261
 
1179
1262
  if (filters.lastDoc) {
@@ -1194,7 +1277,20 @@ export class ProcedureService extends BaseService {
1194
1277
  );
1195
1278
 
1196
1279
  // Apply all client-side filters using centralized function
1280
+ console.log('[PROCEDURE_SERVICE] Before applyInMemoryFilters (Strategy 3):', {
1281
+ procedureCount: procedures.length,
1282
+ procedureTechnology: filters.procedureTechnology,
1283
+ filtersObject: {
1284
+ procedureTechnology: filters.procedureTechnology,
1285
+ procedureFamily: filters.procedureFamily,
1286
+ procedureCategory: filters.procedureCategory,
1287
+ procedureSubcategory: filters.procedureSubcategory,
1288
+ },
1289
+ });
1197
1290
  procedures = this.applyInMemoryFilters(procedures, filters);
1291
+ console.log('[PROCEDURE_SERVICE] After applyInMemoryFilters (Strategy 3):', {
1292
+ procedureCount: procedures.length,
1293
+ });
1198
1294
 
1199
1295
  const lastDoc =
1200
1296
  querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
@@ -1265,6 +1361,14 @@ export class ProcedureService extends BaseService {
1265
1361
  ): (Procedure & { distance?: number })[] {
1266
1362
  let filteredProcedures = [...procedures]; // Create copy to avoid mutating original
1267
1363
 
1364
+ // Debug: Log what filters we received
1365
+ console.log('[PROCEDURE_SERVICE] applyInMemoryFilters called:', {
1366
+ procedureCount: procedures.length,
1367
+ procedureTechnology: filters.procedureTechnology,
1368
+ hasTechnologyFilter: !!filters.procedureTechnology,
1369
+ allFilterKeys: Object.keys(filters).filter(k => filters[k] !== undefined && filters[k] !== null),
1370
+ });
1371
+
1268
1372
  // Name search filter
1269
1373
  if (filters.nameSearch && filters.nameSearch.trim()) {
1270
1374
  const searchTerm = filters.nameSearch.trim().toLowerCase();
@@ -1348,12 +1452,21 @@ export class ProcedureService extends BaseService {
1348
1452
 
1349
1453
  // Technology filtering
1350
1454
  if (filters.procedureTechnology) {
1455
+ const beforeCount = filteredProcedures.length;
1351
1456
  filteredProcedures = filteredProcedures.filter(
1352
1457
  procedure => procedure.technology?.id === filters.procedureTechnology,
1353
1458
  );
1354
1459
  console.log(
1355
- `[PROCEDURE_SERVICE] Applied technology filter, results: ${filteredProcedures.length}`,
1460
+ `[PROCEDURE_SERVICE] Applied technology filter (${filters.procedureTechnology}), before: ${beforeCount}, after: ${filteredProcedures.length}`,
1356
1461
  );
1462
+ // Log sample technology IDs for debugging
1463
+ if (beforeCount > filteredProcedures.length) {
1464
+ const filteredOut = procedures
1465
+ .filter(p => p.technology?.id !== filters.procedureTechnology)
1466
+ .slice(0, 3)
1467
+ .map(p => ({ id: p.id, techId: p.technology?.id, name: p.name }));
1468
+ console.log('[PROCEDURE_SERVICE] Filtered out sample procedures:', filteredOut);
1469
+ }
1357
1470
  }
1358
1471
 
1359
1472
  // Practitioner filtering
@@ -29,6 +29,57 @@ export class ReviewService extends BaseService {
29
29
  super(db, auth, app);
30
30
  }
31
31
 
32
+ /**
33
+ * Helper function to convert Firestore Timestamps to Date objects
34
+ * @param timestamp The timestamp to convert
35
+ * @returns A JavaScript Date object or null
36
+ */
37
+ private convertTimestamp(timestamp: any): Date {
38
+ if (!timestamp) {
39
+ return new Date();
40
+ }
41
+
42
+ // Firebase Timestamp object with __isTimestamp
43
+ if (timestamp && timestamp.__isTimestamp === true && typeof timestamp.seconds === 'number') {
44
+ return new Date(timestamp.seconds * 1000 + (timestamp.nanoseconds || 0) / 1000000);
45
+ }
46
+
47
+ // Firebase Firestore Timestamp with toDate method
48
+ if (timestamp && timestamp.toDate && typeof timestamp.toDate === 'function') {
49
+ return timestamp.toDate();
50
+ }
51
+
52
+ // Already a Date object
53
+ if (timestamp instanceof Date) {
54
+ return timestamp;
55
+ }
56
+
57
+ // String or number
58
+ if (typeof timestamp === 'string' || typeof timestamp === 'number') {
59
+ const date = new Date(timestamp);
60
+ if (!isNaN(date.getTime())) {
61
+ return date;
62
+ }
63
+ }
64
+
65
+ return new Date();
66
+ }
67
+
68
+ /**
69
+ * Converts a Firestore document to a Review object with proper date handling
70
+ * @param docData The raw Firestore document data
71
+ * @returns A Review object with properly converted dates
72
+ */
73
+ private convertDocToReview(docData: any): Review {
74
+ const review = docData as Review;
75
+
76
+ // Convert main review timestamps (all sub-reviews share the same creation date)
77
+ review.createdAt = this.convertTimestamp(docData.createdAt);
78
+ review.updatedAt = this.convertTimestamp(docData.updatedAt);
79
+
80
+ return review;
81
+ }
82
+
32
83
  /**
33
84
  * Creates a new review
34
85
  * @param data - The review data to create
@@ -206,7 +257,7 @@ export class ReviewService extends BaseService {
206
257
  return null;
207
258
  }
208
259
 
209
- const review = { ...docSnap.data(), id: reviewId } as Review;
260
+ const review = { ...this.convertDocToReview(docSnap.data()), id: reviewId };
210
261
 
211
262
  try {
212
263
  // Fetch the associated appointment to enhance with entity names
@@ -293,7 +344,7 @@ export class ReviewService extends BaseService {
293
344
  async getReviewsByPatient(patientId: string): Promise<Review[]> {
294
345
  const q = query(collection(this.db, REVIEWS_COLLECTION), where('patientId', '==', patientId));
295
346
  const snapshot = await getDocs(q);
296
- const reviews = snapshot.docs.map(doc => doc.data() as Review);
347
+ const reviews = snapshot.docs.map(doc => this.convertDocToReview(doc.data()));
297
348
 
298
349
  // Enhance reviews with entity names from appointments
299
350
  const enhancedReviews = await Promise.all(
@@ -364,8 +415,8 @@ export class ReviewService extends BaseService {
364
415
  );
365
416
  const snapshot = await getDocs(q);
366
417
  const reviews = snapshot.docs.map(doc => {
367
- const data = doc.data() as Review;
368
- return { ...data, id: doc.id };
418
+ const review = this.convertDocToReview(doc.data());
419
+ return { ...review, id: doc.id };
369
420
  });
370
421
 
371
422
  console.log('🔍 ReviewService.getReviewsByClinic - Found reviews before enhancement:', {
@@ -459,8 +510,8 @@ export class ReviewService extends BaseService {
459
510
  );
460
511
  const snapshot = await getDocs(q);
461
512
  const reviews = snapshot.docs.map(doc => {
462
- const data = doc.data() as Review;
463
- return { ...data, id: doc.id };
513
+ const review = this.convertDocToReview(doc.data());
514
+ return { ...review, id: doc.id };
464
515
  });
465
516
 
466
517
  console.log('🔍 ReviewService.getReviewsByPractitioner - Found reviews before enhancement:', {
@@ -644,7 +695,7 @@ export class ReviewService extends BaseService {
644
695
  return null;
645
696
  }
646
697
 
647
- return snapshot.docs[0].data() as Review;
698
+ return this.convertDocToReview(snapshot.docs[0].data());
648
699
  }
649
700
 
650
701
  /**