@blackcode_sa/metaestetics-api 1.12.67 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/index.d.mts +801 -2
- package/dist/admin/index.d.ts +801 -2
- package/dist/admin/index.js +2332 -153
- package/dist/admin/index.mjs +2321 -153
- package/dist/backoffice/index.d.mts +40 -0
- package/dist/backoffice/index.d.ts +40 -0
- package/dist/backoffice/index.js +118 -18
- package/dist/backoffice/index.mjs +118 -20
- package/dist/index.d.mts +1097 -2
- package/dist/index.d.ts +1097 -2
- package/dist/index.js +4224 -2091
- package/dist/index.mjs +3941 -1821
- package/package.json +1 -1
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +140 -0
- package/src/admin/analytics/analytics.admin.service.ts +278 -0
- package/src/admin/analytics/index.ts +2 -0
- package/src/admin/index.ts +6 -0
- package/src/backoffice/services/README.md +17 -0
- package/src/backoffice/services/analytics.service.proposal.md +863 -0
- package/src/backoffice/services/analytics.service.summary.md +143 -0
- package/src/backoffice/services/category.service.ts +49 -6
- package/src/backoffice/services/subcategory.service.ts +50 -6
- package/src/backoffice/services/technology.service.ts +53 -6
- package/src/services/analytics/ARCHITECTURE.md +199 -0
- package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -0
- package/src/services/analytics/GROUPED_ANALYTICS.md +501 -0
- package/src/services/analytics/QUICK_START.md +393 -0
- package/src/services/analytics/README.md +287 -0
- package/src/services/analytics/SUMMARY.md +141 -0
- package/src/services/analytics/USAGE_GUIDE.md +518 -0
- package/src/services/analytics/analytics-cloud.service.ts +222 -0
- package/src/services/analytics/analytics.service.ts +1632 -0
- package/src/services/analytics/index.ts +3 -0
- package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -0
- package/src/services/analytics/utils/cost-calculation.utils.ts +154 -0
- package/src/services/analytics/utils/grouping.utils.ts +394 -0
- package/src/services/analytics/utils/stored-analytics.utils.ts +347 -0
- package/src/services/analytics/utils/time-calculation.utils.ts +186 -0
- package/src/services/appointment/appointment.service.ts +50 -6
- package/src/services/index.ts +1 -0
- package/src/services/procedure/procedure.service.ts +3 -3
- package/src/types/analytics/analytics.types.ts +500 -0
- package/src/types/analytics/grouped-analytics.types.ts +148 -0
- package/src/types/analytics/index.ts +4 -0
- package/src/types/analytics/stored-analytics.types.ts +137 -0
- package/src/types/index.ts +3 -0
- package/src/types/notifications/index.ts +21 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Analytics Service - Executive Summary
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
This document provides a high-level summary of the proposed Analytics Service for the Clinic Admin app. The full detailed proposal can be found in [analytics.service.proposal.md](./analytics.service.proposal.md).
|
|
6
|
+
|
|
7
|
+
## Purpose
|
|
8
|
+
|
|
9
|
+
The Analytics Service will provide comprehensive financial and operational intelligence to clinic administrators, enabling data-driven decision making across:
|
|
10
|
+
|
|
11
|
+
- **Practitioner Performance**: Track doctor efficiency, revenue generation, and patient satisfaction
|
|
12
|
+
- **Procedure Analytics**: Understand procedure popularity, profitability, and product usage
|
|
13
|
+
- **Financial Intelligence**: Monitor revenue, costs, payment status, and trends
|
|
14
|
+
- **Operational Efficiency**: Analyze time utilization, cancellations, and no-shows
|
|
15
|
+
- **Patient Insights**: Track patient lifetime value, retention, and behavior patterns
|
|
16
|
+
|
|
17
|
+
## Key Data Sources
|
|
18
|
+
|
|
19
|
+
The service will aggregate data from:
|
|
20
|
+
|
|
21
|
+
1. **Appointments Collection**: Primary source for all appointment-related metrics
|
|
22
|
+
2. **Procedures Collection**: Procedure metadata and categorization
|
|
23
|
+
3. **Practitioners Collection**: Doctor information and associations
|
|
24
|
+
4. **Patients Collection**: Patient profiles and history
|
|
25
|
+
5. **Clinics Collection**: Clinic and branch information
|
|
26
|
+
6. **Products Collection**: Product usage and pricing data
|
|
27
|
+
|
|
28
|
+
## Core Analytics Categories
|
|
29
|
+
|
|
30
|
+
### 1. Practitioner Analytics
|
|
31
|
+
- Total and completed appointments
|
|
32
|
+
- Cancellation and no-show rates
|
|
33
|
+
- Time efficiency (booked vs actual)
|
|
34
|
+
- Revenue generation
|
|
35
|
+
- Patient retention
|
|
36
|
+
|
|
37
|
+
### 2. Procedure Analytics
|
|
38
|
+
- Appointment counts and popularity
|
|
39
|
+
- Revenue and profitability
|
|
40
|
+
- Cancellation rates
|
|
41
|
+
- Product usage patterns
|
|
42
|
+
- Performance by category/technology
|
|
43
|
+
|
|
44
|
+
### 3. Time Analytics
|
|
45
|
+
- Booked vs actual time comparison
|
|
46
|
+
- Efficiency percentages
|
|
47
|
+
- Overrun/underutilization analysis
|
|
48
|
+
- Peak hours and trends
|
|
49
|
+
|
|
50
|
+
### 4. Cancellation & No-Show Analytics
|
|
51
|
+
- Rates by clinic, practitioner, patient, procedure
|
|
52
|
+
- Cancellation reasons breakdown
|
|
53
|
+
- Patterns and trends
|
|
54
|
+
- Lead time analysis
|
|
55
|
+
|
|
56
|
+
### 5. Financial Analytics
|
|
57
|
+
- Total and average revenue
|
|
58
|
+
- Cost per patient/appointment
|
|
59
|
+
- Payment status breakdown
|
|
60
|
+
- Revenue trends
|
|
61
|
+
- Product cost analysis
|
|
62
|
+
|
|
63
|
+
### 6. Product Usage Analytics
|
|
64
|
+
- Products used per appointment
|
|
65
|
+
- Revenue contribution
|
|
66
|
+
- Usage by procedure/zone
|
|
67
|
+
- Quantity and pricing trends
|
|
68
|
+
|
|
69
|
+
### 7. Patient Analytics
|
|
70
|
+
- Lifetime value
|
|
71
|
+
- Retention rates
|
|
72
|
+
- Appointment frequency
|
|
73
|
+
- Cancellation patterns
|
|
74
|
+
|
|
75
|
+
### 8. Clinic Analytics
|
|
76
|
+
- Overall performance metrics
|
|
77
|
+
- Practitioner comparisons
|
|
78
|
+
- Procedure popularity
|
|
79
|
+
- Efficiency metrics
|
|
80
|
+
|
|
81
|
+
## Proposed Service Structure
|
|
82
|
+
|
|
83
|
+
The `AnalyticsService` will extend `BaseService` and provide methods organized by analytics category:
|
|
84
|
+
|
|
85
|
+
- `getPractitionerAnalytics()` - Comprehensive practitioner metrics
|
|
86
|
+
- `getProcedureAnalytics()` - Procedure performance data
|
|
87
|
+
- `getTimeEfficiencyMetrics()` - Time utilization analysis
|
|
88
|
+
- `getCancellationMetrics()` - Cancellation insights
|
|
89
|
+
- `getRevenueMetrics()` - Financial intelligence
|
|
90
|
+
- `getProductUsageMetrics()` - Product analytics
|
|
91
|
+
- `getPatientAnalytics()` - Patient insights
|
|
92
|
+
- `getClinicAnalytics()` - Clinic performance
|
|
93
|
+
- `getDashboardData()` - Comprehensive dashboard aggregation
|
|
94
|
+
|
|
95
|
+
## Key Calculations
|
|
96
|
+
|
|
97
|
+
### Cost Calculation Priority
|
|
98
|
+
1. `metadata.finalbilling.finalPrice` (if available)
|
|
99
|
+
2. Sum of `metadata.zonesData` subtotals
|
|
100
|
+
3. `appointment.cost` (fallback)
|
|
101
|
+
|
|
102
|
+
### Time Efficiency
|
|
103
|
+
```
|
|
104
|
+
efficiency = (actualDuration / bookedDuration) * 100
|
|
105
|
+
overrun = actualDuration > bookedDuration ? difference : 0
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Cancellation Rate
|
|
109
|
+
```
|
|
110
|
+
cancellationRate = (canceledCount / totalAppointments) * 100
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Implementation Phases
|
|
114
|
+
|
|
115
|
+
1. **Phase 1**: Core infrastructure and utilities
|
|
116
|
+
2. **Phase 2**: Basic metrics (practitioner, procedure, financial)
|
|
117
|
+
3. **Phase 3**: Advanced analytics (time, products, patients)
|
|
118
|
+
4. **Phase 4**: Dashboard and trend analysis
|
|
119
|
+
|
|
120
|
+
## Performance Considerations
|
|
121
|
+
|
|
122
|
+
- Firestore composite indexes for query optimization
|
|
123
|
+
- Pagination for large datasets
|
|
124
|
+
- Caching strategy for frequently accessed metrics
|
|
125
|
+
- Batch processing for complex aggregations
|
|
126
|
+
- Server-side calculations via Cloud Functions
|
|
127
|
+
|
|
128
|
+
## Next Steps
|
|
129
|
+
|
|
130
|
+
1. Review and approve the detailed proposal
|
|
131
|
+
2. Create TypeScript type definitions
|
|
132
|
+
3. Implement core service structure
|
|
133
|
+
4. Build utility functions for calculations
|
|
134
|
+
5. Implement metrics methods incrementally
|
|
135
|
+
6. Add comprehensive tests
|
|
136
|
+
7. Create API documentation
|
|
137
|
+
8. Integrate with Clinic Admin app
|
|
138
|
+
|
|
139
|
+
## Documentation
|
|
140
|
+
|
|
141
|
+
- **Full Proposal**: [analytics.service.proposal.md](./analytics.service.proposal.md)
|
|
142
|
+
- **Service README**: [README.md](./README.md)
|
|
143
|
+
|
|
@@ -17,6 +17,12 @@ import { Category, CATEGORIES_COLLECTION, ICategoryService } from '../types/cate
|
|
|
17
17
|
import { BaseService } from '../../services/base.service';
|
|
18
18
|
import { ProcedureFamily } from '../types/static/procedure-family.types';
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* ID of the consultation category that should be hidden from admin backoffice.
|
|
22
|
+
* This category is used internally for free consultation procedures.
|
|
23
|
+
*/
|
|
24
|
+
const EXCLUDED_CATEGORY_ID = 'consultation';
|
|
25
|
+
|
|
20
26
|
/**
|
|
21
27
|
* Servis za upravljanje kategorijama procedura.
|
|
22
28
|
* Kategorije su prvi nivo organizacije nakon procedure family (aesthetics/surgery).
|
|
@@ -31,6 +37,14 @@ import { ProcedureFamily } from '../types/static/procedure-family.types';
|
|
|
31
37
|
* });
|
|
32
38
|
*/
|
|
33
39
|
export class CategoryService extends BaseService implements ICategoryService {
|
|
40
|
+
/**
|
|
41
|
+
* Filters out excluded categories from a list.
|
|
42
|
+
* @param categories - List of categories to filter
|
|
43
|
+
* @returns Filtered list without excluded categories
|
|
44
|
+
*/
|
|
45
|
+
private filterExcludedCategories(categories: Category[]): Category[] {
|
|
46
|
+
return categories.filter(cat => cat.id !== EXCLUDED_CATEGORY_ID);
|
|
47
|
+
}
|
|
34
48
|
/**
|
|
35
49
|
* Referenca na Firestore kolekciju kategorija
|
|
36
50
|
*/
|
|
@@ -71,8 +85,10 @@ export class CategoryService extends BaseService implements ICategoryService {
|
|
|
71
85
|
where('family', '==', family),
|
|
72
86
|
where('isActive', '==', active),
|
|
73
87
|
);
|
|
74
|
-
const snapshot = await
|
|
75
|
-
|
|
88
|
+
const snapshot = await getDocs(q);
|
|
89
|
+
// Filter out excluded category and count
|
|
90
|
+
const filteredDocs = snapshot.docs.filter(doc => doc.id !== EXCLUDED_CATEGORY_ID);
|
|
91
|
+
counts[family] = filteredDocs.length;
|
|
76
92
|
}
|
|
77
93
|
return counts;
|
|
78
94
|
}
|
|
@@ -84,13 +100,14 @@ export class CategoryService extends BaseService implements ICategoryService {
|
|
|
84
100
|
async getAllForFilter() {
|
|
85
101
|
const q = query(this.categoriesRef, where('isActive', '==', true));
|
|
86
102
|
const snapshot = await getDocs(q);
|
|
87
|
-
|
|
103
|
+
const categories = snapshot.docs.map(
|
|
88
104
|
doc =>
|
|
89
105
|
({
|
|
90
106
|
id: doc.id,
|
|
91
107
|
...doc.data(),
|
|
92
108
|
} as Category),
|
|
93
109
|
);
|
|
110
|
+
return this.filterExcludedCategories(categories);
|
|
94
111
|
}
|
|
95
112
|
|
|
96
113
|
/**
|
|
@@ -106,13 +123,14 @@ export class CategoryService extends BaseService implements ICategoryService {
|
|
|
106
123
|
orderBy('name'),
|
|
107
124
|
);
|
|
108
125
|
const snapshot = await getDocs(q);
|
|
109
|
-
|
|
126
|
+
const categories = snapshot.docs.map(
|
|
110
127
|
doc =>
|
|
111
128
|
({
|
|
112
129
|
id: doc.id,
|
|
113
130
|
...doc.data(),
|
|
114
131
|
} as Category),
|
|
115
132
|
);
|
|
133
|
+
return this.filterExcludedCategories(categories);
|
|
116
134
|
}
|
|
117
135
|
|
|
118
136
|
/**
|
|
@@ -144,8 +162,9 @@ export class CategoryService extends BaseService implements ICategoryService {
|
|
|
144
162
|
...doc.data(),
|
|
145
163
|
} as Category),
|
|
146
164
|
);
|
|
165
|
+
const filteredCategories = this.filterExcludedCategories(categories);
|
|
147
166
|
const newLastVisible = snapshot.docs[snapshot.docs.length - 1];
|
|
148
|
-
return { categories, lastVisible: newLastVisible };
|
|
167
|
+
return { categories: filteredCategories, lastVisible: newLastVisible };
|
|
149
168
|
}
|
|
150
169
|
|
|
151
170
|
/**
|
|
@@ -180,8 +199,9 @@ export class CategoryService extends BaseService implements ICategoryService {
|
|
|
180
199
|
...doc.data(),
|
|
181
200
|
} as Category),
|
|
182
201
|
);
|
|
202
|
+
const filteredCategories = this.filterExcludedCategories(categories);
|
|
183
203
|
const newLastVisible = snapshot.docs[snapshot.docs.length - 1];
|
|
184
|
-
return { categories, lastVisible: newLastVisible };
|
|
204
|
+
return { categories: filteredCategories, lastVisible: newLastVisible };
|
|
185
205
|
}
|
|
186
206
|
|
|
187
207
|
/**
|
|
@@ -223,6 +243,25 @@ export class CategoryService extends BaseService implements ICategoryService {
|
|
|
223
243
|
* @returns Kategorija ili null ako ne postoji
|
|
224
244
|
*/
|
|
225
245
|
async getById(id: string) {
|
|
246
|
+
// Prevent access to excluded category
|
|
247
|
+
if (id === EXCLUDED_CATEGORY_ID) return null;
|
|
248
|
+
|
|
249
|
+
const docRef = doc(this.categoriesRef, id);
|
|
250
|
+
const docSnap = await getDoc(docRef);
|
|
251
|
+
if (!docSnap.exists()) return null;
|
|
252
|
+
return {
|
|
253
|
+
id: docSnap.id,
|
|
254
|
+
...docSnap.data(),
|
|
255
|
+
} as Category;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Internal method to get category by ID without filtering.
|
|
260
|
+
* Used internally for consultation procedures.
|
|
261
|
+
* @param id - ID of the category to get
|
|
262
|
+
* @returns Category or null if not found
|
|
263
|
+
*/
|
|
264
|
+
async getByIdInternal(id: string): Promise<Category | null> {
|
|
226
265
|
const docRef = doc(this.categoriesRef, id);
|
|
227
266
|
const docSnap = await getDoc(docRef);
|
|
228
267
|
if (!docSnap.exists()) return null;
|
|
@@ -249,6 +288,8 @@ export class CategoryService extends BaseService implements ICategoryService {
|
|
|
249
288
|
const snapshot = await getDocs(q);
|
|
250
289
|
if (snapshot.empty) return null;
|
|
251
290
|
const doc = snapshot.docs[0];
|
|
291
|
+
// Exclude consultation category
|
|
292
|
+
if (doc.id === EXCLUDED_CATEGORY_ID) return null;
|
|
252
293
|
return {
|
|
253
294
|
id: doc.id,
|
|
254
295
|
...doc.data(),
|
|
@@ -299,6 +340,8 @@ export class CategoryService extends BaseService implements ICategoryService {
|
|
|
299
340
|
if (snapshot.empty) break;
|
|
300
341
|
|
|
301
342
|
for (const d of snapshot.docs) {
|
|
343
|
+
// Exclude consultation category from CSV export
|
|
344
|
+
if (d.id === EXCLUDED_CATEGORY_ID) continue;
|
|
302
345
|
const category = ({ id: d.id, ...d.data() } as unknown) as Category;
|
|
303
346
|
rows.push(this.categoryToCsvRow(category));
|
|
304
347
|
}
|
|
@@ -23,6 +23,12 @@ import {
|
|
|
23
23
|
import { BaseService } from "../../services/base.service";
|
|
24
24
|
import { CATEGORIES_COLLECTION } from "../types/category.types";
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* ID of the free-consultation subcategory that should be hidden from admin backoffice.
|
|
28
|
+
* This subcategory is used internally for free consultation procedures.
|
|
29
|
+
*/
|
|
30
|
+
const EXCLUDED_SUBCATEGORY_ID = 'free-consultation';
|
|
31
|
+
|
|
26
32
|
/**
|
|
27
33
|
* Servis za upravljanje podkategorijama procedura.
|
|
28
34
|
* Podkategorije su drugi nivo organizacije i pripadaju određenoj kategoriji.
|
|
@@ -37,6 +43,14 @@ import { CATEGORIES_COLLECTION } from "../types/category.types";
|
|
|
37
43
|
* });
|
|
38
44
|
*/
|
|
39
45
|
export class SubcategoryService extends BaseService {
|
|
46
|
+
/**
|
|
47
|
+
* Filters out excluded subcategories from a list.
|
|
48
|
+
* @param subcategories - List of subcategories to filter
|
|
49
|
+
* @returns Filtered list without excluded subcategories
|
|
50
|
+
*/
|
|
51
|
+
private filterExcludedSubcategories(subcategories: Subcategory[]): Subcategory[] {
|
|
52
|
+
return subcategories.filter(sub => sub.id !== EXCLUDED_SUBCATEGORY_ID);
|
|
53
|
+
}
|
|
40
54
|
/**
|
|
41
55
|
* Vraća referencu na Firestore kolekciju podkategorija za određenu kategoriju
|
|
42
56
|
* @param categoryId - ID roditeljske kategorije
|
|
@@ -90,8 +104,10 @@ export class SubcategoryService extends BaseService {
|
|
|
90
104
|
const categoryId = categoryDoc.id;
|
|
91
105
|
const subcategoriesRef = this.getSubcategoriesRef(categoryId);
|
|
92
106
|
const q = query(subcategoriesRef, where("isActive", "==", active));
|
|
93
|
-
const snapshot = await
|
|
94
|
-
|
|
107
|
+
const snapshot = await getDocs(q);
|
|
108
|
+
// Filter out excluded subcategory and count
|
|
109
|
+
const filteredDocs = snapshot.docs.filter(doc => doc.id !== EXCLUDED_SUBCATEGORY_ID);
|
|
110
|
+
counts[categoryId] = filteredDocs.length;
|
|
95
111
|
}
|
|
96
112
|
|
|
97
113
|
return counts;
|
|
@@ -129,8 +145,9 @@ export class SubcategoryService extends BaseService {
|
|
|
129
145
|
...doc.data(),
|
|
130
146
|
} as Subcategory)
|
|
131
147
|
);
|
|
148
|
+
const filteredSubcategories = this.filterExcludedSubcategories(subcategories);
|
|
132
149
|
const newLastVisible = querySnapshot.docs[querySnapshot.docs.length - 1];
|
|
133
|
-
return { subcategories, lastVisible: newLastVisible };
|
|
150
|
+
return { subcategories: filteredSubcategories, lastVisible: newLastVisible };
|
|
134
151
|
}
|
|
135
152
|
|
|
136
153
|
/**
|
|
@@ -169,8 +186,9 @@ export class SubcategoryService extends BaseService {
|
|
|
169
186
|
...doc.data(),
|
|
170
187
|
} as Subcategory)
|
|
171
188
|
);
|
|
189
|
+
const filteredSubcategories = this.filterExcludedSubcategories(subcategories);
|
|
172
190
|
const newLastVisible = querySnapshot.docs[querySnapshot.docs.length - 1];
|
|
173
|
-
return { subcategories, lastVisible: newLastVisible };
|
|
191
|
+
return { subcategories: filteredSubcategories, lastVisible: newLastVisible };
|
|
174
192
|
}
|
|
175
193
|
|
|
176
194
|
/**
|
|
@@ -184,13 +202,14 @@ export class SubcategoryService extends BaseService {
|
|
|
184
202
|
where("isActive", "==", true)
|
|
185
203
|
);
|
|
186
204
|
const querySnapshot = await getDocs(q);
|
|
187
|
-
|
|
205
|
+
const subcategories = querySnapshot.docs.map(
|
|
188
206
|
(doc) =>
|
|
189
207
|
({
|
|
190
208
|
id: doc.id,
|
|
191
209
|
...doc.data(),
|
|
192
210
|
} as Subcategory)
|
|
193
211
|
);
|
|
212
|
+
return this.filterExcludedSubcategories(subcategories);
|
|
194
213
|
}
|
|
195
214
|
|
|
196
215
|
/**
|
|
@@ -203,13 +222,14 @@ export class SubcategoryService extends BaseService {
|
|
|
203
222
|
where("isActive", "==", true)
|
|
204
223
|
);
|
|
205
224
|
const querySnapshot = await getDocs(q);
|
|
206
|
-
|
|
225
|
+
const subcategories = querySnapshot.docs.map(
|
|
207
226
|
(doc) =>
|
|
208
227
|
({
|
|
209
228
|
id: doc.id,
|
|
210
229
|
...doc.data(),
|
|
211
230
|
} as Subcategory)
|
|
212
231
|
);
|
|
232
|
+
return this.filterExcludedSubcategories(subcategories);
|
|
213
233
|
}
|
|
214
234
|
|
|
215
235
|
/**
|
|
@@ -297,6 +317,26 @@ export class SubcategoryService extends BaseService {
|
|
|
297
317
|
* @returns Podkategorija ili null ako ne postoji
|
|
298
318
|
*/
|
|
299
319
|
async getById(categoryId: string, subcategoryId: string) {
|
|
320
|
+
// Prevent access to excluded subcategory
|
|
321
|
+
if (subcategoryId === EXCLUDED_SUBCATEGORY_ID) return null;
|
|
322
|
+
|
|
323
|
+
const docRef = doc(this.getSubcategoriesRef(categoryId), subcategoryId);
|
|
324
|
+
const docSnap = await getDoc(docRef);
|
|
325
|
+
if (!docSnap.exists()) return null;
|
|
326
|
+
return {
|
|
327
|
+
id: docSnap.id,
|
|
328
|
+
...docSnap.data(),
|
|
329
|
+
} as Subcategory;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Internal method to get subcategory by ID without filtering.
|
|
334
|
+
* Used internally for consultation procedures.
|
|
335
|
+
* @param categoryId - ID of the category
|
|
336
|
+
* @param subcategoryId - ID of the subcategory to get
|
|
337
|
+
* @returns Subcategory or null if not found
|
|
338
|
+
*/
|
|
339
|
+
async getByIdInternal(categoryId: string, subcategoryId: string): Promise<Subcategory | null> {
|
|
300
340
|
const docRef = doc(this.getSubcategoriesRef(categoryId), subcategoryId);
|
|
301
341
|
const docSnap = await getDoc(docRef);
|
|
302
342
|
if (!docSnap.exists()) return null;
|
|
@@ -322,6 +362,8 @@ export class SubcategoryService extends BaseService {
|
|
|
322
362
|
const querySnapshot = await getDocs(q);
|
|
323
363
|
if (querySnapshot.empty) return null;
|
|
324
364
|
const doc = querySnapshot.docs[0];
|
|
365
|
+
// Exclude free-consultation subcategory
|
|
366
|
+
if (doc.id === EXCLUDED_SUBCATEGORY_ID) return null;
|
|
325
367
|
return {
|
|
326
368
|
id: doc.id,
|
|
327
369
|
...doc.data(),
|
|
@@ -375,6 +417,8 @@ export class SubcategoryService extends BaseService {
|
|
|
375
417
|
if (snapshot.empty) break;
|
|
376
418
|
|
|
377
419
|
for (const d of snapshot.docs) {
|
|
420
|
+
// Exclude free-consultation subcategory from CSV export
|
|
421
|
+
if (d.id === EXCLUDED_SUBCATEGORY_ID) continue;
|
|
378
422
|
const subcategory = ({ id: d.id, ...d.data() } as unknown) as Subcategory;
|
|
379
423
|
rows.push(this.subcategoryToCsvRow(subcategory));
|
|
380
424
|
}
|
|
@@ -33,6 +33,12 @@ import { ProcedureFamily } from '../types/static/procedure-family.types';
|
|
|
33
33
|
import { Practitioner, PractitionerCertification } from '../../types/practitioner';
|
|
34
34
|
import { Product, PRODUCTS_COLLECTION } from '../types/product.types';
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* ID of the free-consultation-tech technology that should be hidden from admin backoffice.
|
|
38
|
+
* This technology is used internally for free consultation procedures.
|
|
39
|
+
*/
|
|
40
|
+
const EXCLUDED_TECHNOLOGY_ID = 'free-consultation-tech';
|
|
41
|
+
|
|
36
42
|
/**
|
|
37
43
|
* Default vrednosti za sertifikaciju
|
|
38
44
|
*/
|
|
@@ -45,6 +51,14 @@ const DEFAULT_CERTIFICATION_REQUIREMENT: CertificationRequirement = {
|
|
|
45
51
|
* Service for managing technologies.
|
|
46
52
|
*/
|
|
47
53
|
export class TechnologyService extends BaseService implements ITechnologyService {
|
|
54
|
+
/**
|
|
55
|
+
* Filters out excluded technologies from a list.
|
|
56
|
+
* @param technologies - List of technologies to filter
|
|
57
|
+
* @returns Filtered list without excluded technologies
|
|
58
|
+
*/
|
|
59
|
+
private filterExcludedTechnologies(technologies: Technology[]): Technology[] {
|
|
60
|
+
return technologies.filter(tech => tech.id !== EXCLUDED_TECHNOLOGY_ID);
|
|
61
|
+
}
|
|
48
62
|
/**
|
|
49
63
|
* Reference to the Firestore collection of technologies.
|
|
50
64
|
*/
|
|
@@ -100,6 +114,8 @@ export class TechnologyService extends BaseService implements ITechnologyService
|
|
|
100
114
|
const snapshot = await getDocs(q);
|
|
101
115
|
const counts: Record<string, number> = {};
|
|
102
116
|
snapshot.docs.forEach(doc => {
|
|
117
|
+
// Exclude free-consultation-tech from counts
|
|
118
|
+
if (doc.id === EXCLUDED_TECHNOLOGY_ID) return;
|
|
103
119
|
const tech = doc.data() as Technology;
|
|
104
120
|
counts[tech.subcategoryId] = (counts[tech.subcategoryId] || 0) + 1;
|
|
105
121
|
});
|
|
@@ -116,6 +132,8 @@ export class TechnologyService extends BaseService implements ITechnologyService
|
|
|
116
132
|
const snapshot = await getDocs(q);
|
|
117
133
|
const counts: Record<string, number> = {};
|
|
118
134
|
snapshot.docs.forEach(doc => {
|
|
135
|
+
// Exclude free-consultation-tech from counts
|
|
136
|
+
if (doc.id === EXCLUDED_TECHNOLOGY_ID) return;
|
|
119
137
|
const tech = doc.data() as Technology;
|
|
120
138
|
counts[tech.categoryId] = (counts[tech.categoryId] || 0) + 1;
|
|
121
139
|
});
|
|
@@ -151,8 +169,9 @@ export class TechnologyService extends BaseService implements ITechnologyService
|
|
|
151
169
|
...doc.data(),
|
|
152
170
|
} as Technology),
|
|
153
171
|
);
|
|
172
|
+
const filteredTechnologies = this.filterExcludedTechnologies(technologies);
|
|
154
173
|
const newLastVisible = snapshot.docs[snapshot.docs.length - 1];
|
|
155
|
-
return { technologies, lastVisible: newLastVisible };
|
|
174
|
+
return { technologies: filteredTechnologies, lastVisible: newLastVisible };
|
|
156
175
|
}
|
|
157
176
|
|
|
158
177
|
/**
|
|
@@ -187,8 +206,9 @@ export class TechnologyService extends BaseService implements ITechnologyService
|
|
|
187
206
|
...doc.data(),
|
|
188
207
|
} as Technology),
|
|
189
208
|
);
|
|
209
|
+
const filteredTechnologies = this.filterExcludedTechnologies(technologies);
|
|
190
210
|
const newLastVisible = snapshot.docs[snapshot.docs.length - 1];
|
|
191
|
-
return { technologies, lastVisible: newLastVisible };
|
|
211
|
+
return { technologies: filteredTechnologies, lastVisible: newLastVisible };
|
|
192
212
|
}
|
|
193
213
|
|
|
194
214
|
/**
|
|
@@ -223,8 +243,9 @@ export class TechnologyService extends BaseService implements ITechnologyService
|
|
|
223
243
|
...doc.data(),
|
|
224
244
|
} as Technology),
|
|
225
245
|
);
|
|
246
|
+
const filteredTechnologies = this.filterExcludedTechnologies(technologies);
|
|
226
247
|
const newLastVisible = snapshot.docs[snapshot.docs.length - 1];
|
|
227
|
-
return { technologies, lastVisible: newLastVisible };
|
|
248
|
+
return { technologies: filteredTechnologies, lastVisible: newLastVisible };
|
|
228
249
|
}
|
|
229
250
|
|
|
230
251
|
/**
|
|
@@ -300,6 +321,25 @@ export class TechnologyService extends BaseService implements ITechnologyService
|
|
|
300
321
|
* @returns The technology or null if it doesn't exist.
|
|
301
322
|
*/
|
|
302
323
|
async getById(id: string): Promise<Technology | null> {
|
|
324
|
+
// Prevent access to excluded technology
|
|
325
|
+
if (id === EXCLUDED_TECHNOLOGY_ID) return null;
|
|
326
|
+
|
|
327
|
+
const docRef = doc(this.technologiesRef, id);
|
|
328
|
+
const docSnap = await getDoc(docRef);
|
|
329
|
+
if (!docSnap.exists()) return null;
|
|
330
|
+
return {
|
|
331
|
+
id: docSnap.id,
|
|
332
|
+
...docSnap.data(),
|
|
333
|
+
} as Technology;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Internal method to get technology by ID without filtering.
|
|
338
|
+
* Used internally for consultation procedures.
|
|
339
|
+
* @param id - The ID of the requested technology
|
|
340
|
+
* @returns The technology or null if it doesn't exist
|
|
341
|
+
*/
|
|
342
|
+
async getByIdInternal(id: string): Promise<Technology | null> {
|
|
303
343
|
const docRef = doc(this.technologiesRef, id);
|
|
304
344
|
const docSnap = await getDoc(docRef);
|
|
305
345
|
if (!docSnap.exists()) return null;
|
|
@@ -324,6 +364,8 @@ export class TechnologyService extends BaseService implements ITechnologyService
|
|
|
324
364
|
const snapshot = await getDocs(q);
|
|
325
365
|
if (snapshot.empty) return null;
|
|
326
366
|
const doc = snapshot.docs[0];
|
|
367
|
+
// Exclude free-consultation-tech
|
|
368
|
+
if (doc.id === EXCLUDED_TECHNOLOGY_ID) return null;
|
|
327
369
|
return {
|
|
328
370
|
id: doc.id,
|
|
329
371
|
...doc.data(),
|
|
@@ -780,13 +822,14 @@ export class TechnologyService extends BaseService implements ITechnologyService
|
|
|
780
822
|
orderBy('name'),
|
|
781
823
|
);
|
|
782
824
|
const snapshot = await getDocs(q);
|
|
783
|
-
|
|
825
|
+
const technologies = snapshot.docs.map(
|
|
784
826
|
doc =>
|
|
785
827
|
({
|
|
786
828
|
id: doc.id,
|
|
787
829
|
...doc.data(),
|
|
788
830
|
} as Technology),
|
|
789
831
|
);
|
|
832
|
+
return this.filterExcludedTechnologies(technologies);
|
|
790
833
|
}
|
|
791
834
|
|
|
792
835
|
/**
|
|
@@ -806,13 +849,14 @@ export class TechnologyService extends BaseService implements ITechnologyService
|
|
|
806
849
|
orderBy('name'),
|
|
807
850
|
);
|
|
808
851
|
const snapshot = await getDocs(q);
|
|
809
|
-
|
|
852
|
+
const technologies = snapshot.docs.map(
|
|
810
853
|
doc =>
|
|
811
854
|
({
|
|
812
855
|
id: doc.id,
|
|
813
856
|
...doc.data(),
|
|
814
857
|
} as Technology),
|
|
815
858
|
);
|
|
859
|
+
return this.filterExcludedTechnologies(technologies);
|
|
816
860
|
}
|
|
817
861
|
|
|
818
862
|
/**
|
|
@@ -825,13 +869,14 @@ export class TechnologyService extends BaseService implements ITechnologyService
|
|
|
825
869
|
orderBy('name'),
|
|
826
870
|
);
|
|
827
871
|
const snapshot = await getDocs(q);
|
|
828
|
-
|
|
872
|
+
const technologies = snapshot.docs.map(
|
|
829
873
|
doc =>
|
|
830
874
|
({
|
|
831
875
|
id: doc.id,
|
|
832
876
|
...doc.data(),
|
|
833
877
|
} as Technology),
|
|
834
878
|
);
|
|
879
|
+
return this.filterExcludedTechnologies(technologies);
|
|
835
880
|
}
|
|
836
881
|
|
|
837
882
|
// ==========================================
|
|
@@ -1040,6 +1085,8 @@ export class TechnologyService extends BaseService implements ITechnologyService
|
|
|
1040
1085
|
if (snapshot.empty) break;
|
|
1041
1086
|
|
|
1042
1087
|
for (const d of snapshot.docs) {
|
|
1088
|
+
// Exclude free-consultation-tech from CSV export
|
|
1089
|
+
if (d.id === EXCLUDED_TECHNOLOGY_ID) continue;
|
|
1043
1090
|
const technology = ({ id: d.id, ...d.data() } as unknown) as Technology;
|
|
1044
1091
|
// Fetch products for this technology
|
|
1045
1092
|
const productNames = await this.getProductNamesForTechnology(technology.id!);
|